Compare commits

...

14 Commits

Author SHA1 Message Date
65e8ae5f75 Merge pull request 'kotlin is here' (#3) from develop into main
Reviewed-on: #3
2025-07-21 22:13:29 +03:00
363a82d0c5 customtheme 2025-07-21 20:05:22 +03:00
e83535e871 loginscreen 2025-07-19 02:22:26 +03:00
224cd8e411 kmp init 2025-07-19 01:00:54 +03:00
3de0277a0b revert 561a5df648
revert kmp init
2025-07-19 00:54:08 +03:00
0ea15cdacd revert f213a11368
revert kmp init
2025-07-19 00:53:59 +03:00
44a5bd9d67 revert 91f6ce8288
revert kmp init
2025-07-19 00:52:19 +03:00
91f6ce8288 kmp init 2025-07-19 00:49:47 +03:00
f213a11368 kmp init 2025-07-19 00:47:17 +03:00
561a5df648 kmp init 2025-07-19 00:46:12 +03:00
f063d2126b Merge pull request '~' (#2) from develop into main
Reviewed-on: #2
2025-07-17 13:39:59 +03:00
e987a00707 ~ 2025-07-17 13:39:19 +03:00
154719f7ed Merge pull request 'del fastapi-users' (#1) from develop into main
Reviewed-on: #1
2025-07-16 17:02:08 +03:00
87b1646f85 del fastapi-users 2025-07-16 17:01:10 +03:00
33 changed files with 916 additions and 336 deletions

19
client/.gitignore vendored
View File

@ -62,6 +62,25 @@ local_settings.py
db.sqlite3
db.sqlite3-journal
*.iml
.kotlin
.gradle
**/build/
xcuserdata
!src/**/build/
local.properties
.idea
.DS_Store
captures
.externalNativeBuild
.cxx
*.xcodeproj/*
!*.xcodeproj/project.pbxproj
!*.xcodeproj/xcshareddata/
!*.xcodeproj/project.xcworkspace/
!*.xcworkspace/contents.xcworkspacedata
**/xcshareddata/WorkspaceSettings.xcsettings
# Flask stuff:
instance/
.webassets-cache

8
client/build.gradle.kts Normal file
View File

@ -0,0 +1,8 @@
plugins {
// this is necessary to avoid the plugins to be loaded multiple times
// in each subproject's classloader
alias(libs.plugins.composeHotReload) apply false
alias(libs.plugins.composeMultiplatform) apply false
alias(libs.plugins.composeCompiler) apply false
alias(libs.plugins.kotlinMultiplatform) apply false
}

View File

@ -0,0 +1,72 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpackConfig
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.composeHotReload)
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.20"
}
kotlin {
jvm("desktop")
@OptIn(ExperimentalWasmDsl::class)
wasmJs {
outputModuleName.set("composeApp")
browser {
val rootDirPath = project.rootDir.path
val projectDirPath = project.projectDir.path
commonWebpackConfig {
outputFileName = "composeApp.js"
devServer = (devServer ?: KotlinWebpackConfig.DevServer()).apply {
static = (static ?: mutableListOf()).apply {
// Serve sources to debug inside browser
add(rootDirPath)
add(projectDirPath)
}
}
}
}
binaries.executable()
}
sourceSets {
val desktopMain by getting
commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.components.uiToolingPreview)
implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation("org.jetbrains.androidx.navigation:navigation-compose:2.9.0-beta03")
}
commonTest.dependencies {
implementation(libs.kotlin.test)
}
desktopMain.dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutinesSwing)
}
}
}
compose.desktop {
application {
mainClass = "su.sonoma.sclient.MainKt"
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "su.sonoma.sclient"
packageVersion = "1.0.0"
}
}
}

View File

@ -0,0 +1,44 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="450dp"
android:height="450dp"
android:viewportWidth="64"
android:viewportHeight="64">
<path
android:pathData="M56.25,18V46L32,60 7.75,46V18L32,4Z"
android:fillColor="#6075f2"/>
<path
android:pathData="m41.5,26.5v11L32,43V60L56.25,46V18Z"
android:fillColor="#6b57ff"/>
<path
android:pathData="m32,43 l-9.5,-5.5v-11L7.75,18V46L32,60Z">
<aapt:attr name="android:fillColor">
<gradient
android:centerX="23.131"
android:centerY="18.441"
android:gradientRadius="42.132"
android:type="radial">
<item android:offset="0" android:color="#FF5383EC"/>
<item android:offset="0.867" android:color="#FF7F52FF"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M22.5,26.5 L32,21 41.5,26.5 56.25,18 32,4 7.75,18Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="44.172"
android:startY="4.377"
android:endX="17.973"
android:endY="34.035"
android:type="linear">
<item android:offset="0" android:color="#FF33C3FF"/>
<item android:offset="0.878" android:color="#FF5383EC"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="m32,21 l9.526,5.5v11L32,43 22.474,37.5v-11z"
android:fillColor="#000000"/>
</vector>

View File

@ -0,0 +1,21 @@
package su.sonoma.sclient
import androidx.compose.material3.ColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.navigation.NavController
import org.jetbrains.compose.ui.tooling.preview.Preview
@Composable
@Preview
fun App(
onNavHostReady: suspend (NavController) -> Unit = {}
) {
CustomTheme {
val navController = Navigation().getNavController()
LaunchedEffect(navController) {
onNavHostReady(navController)
}
}
}

View File

@ -0,0 +1,66 @@
package su.sonoma.sclient
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import org.jetbrains.compose.resources.Font
import sclient.composeapp.generated.resources.Res
import sclient.composeapp.generated.resources.consolas
import sclient.composeapp.generated.resources.consolasbold
@Composable
fun CustomTheme(
content: @Composable () -> Unit
) {
val darkTheme = isSystemInDarkTheme()
val colors = if (darkTheme) {
darkColorScheme()
} else {
lightColorScheme()
}
val fontFamily = FontFamily(Font(Res.font.consolas, FontWeight.Normal))
val typography = InterTypography()
MaterialTheme(
colorScheme = colors,
content = content,
typography = typography
)
}
@Composable
private fun InterTypography(): Typography {
val interFont = FontFamily(
Font(Res.font.consolas, FontWeight.Normal),
Font(Res.font.consolasbold, FontWeight.Bold),
)
return with(MaterialTheme.typography) {
copy(
displayLarge = displayLarge.copy(fontFamily = interFont, fontWeight = FontWeight.Bold),
displayMedium = displayMedium.copy(fontFamily = interFont, fontWeight = FontWeight.Bold),
displaySmall = displaySmall.copy(fontFamily = interFont, fontWeight = FontWeight.Bold),
headlineLarge = headlineLarge.copy(fontFamily = interFont, fontWeight = FontWeight.Bold),
headlineMedium = headlineMedium.copy(fontFamily = interFont, fontWeight = FontWeight.Bold),
headlineSmall = headlineSmall.copy(fontFamily = interFont, fontWeight = FontWeight.Bold),
titleLarge = titleLarge.copy(fontFamily = interFont, fontWeight = FontWeight.Bold),
titleMedium = titleMedium.copy(fontFamily = interFont, fontWeight = FontWeight.Bold),
titleSmall = titleSmall.copy(fontFamily = interFont, fontWeight = FontWeight.Bold),
labelLarge = labelLarge.copy(fontFamily = interFont, fontWeight = FontWeight.Normal),
labelMedium = labelMedium.copy(fontFamily = interFont, fontWeight = FontWeight.Normal),
labelSmall = labelSmall.copy(fontFamily = interFont, fontWeight = FontWeight.Normal),
bodyLarge = bodyLarge.copy(fontFamily = interFont, fontWeight = FontWeight.Normal),
bodyMedium = bodyMedium.copy(fontFamily = interFont, fontWeight = FontWeight.Normal),
bodySmall = bodySmall.copy(fontFamily = interFont, fontWeight = FontWeight.Normal),
)
}
}

View File

@ -0,0 +1,26 @@
package su.sonoma.sclient
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import kotlinx.serialization.Serializable
import su.sonoma.sclient.screen.LoginScreen
@Serializable
class Navigation {
@Serializable
object Login
@Composable
fun getNavController(): NavHostController {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = Login) {
composable<Login> { LoginScreen() }
}
return navController
}
}

View File

@ -0,0 +1,53 @@
package su.sonoma.sclient.screen
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.material3.Button
import androidx.compose.material3.Scaffold
import su.sonoma.sclient.CustomTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import org.jetbrains.compose.ui.tooling.preview.Preview
@Composable
@Preview
fun LoginScreen() {
Scaffold {
Column(
modifier = Modifier
.safeContentPadding()
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
var login by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
TextField(
value = login,
onValueChange = { login = it },
label = { Text("Login") }
)
TextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") }
)
Button(
onClick = { print("$login, $password") }
) {
Text("Login")
}
}
}
}

View File

@ -0,0 +1,13 @@
package su.sonoma.sclient
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
fun main() = application {
Window(
onCloseRequest = ::exitApplication,
title = "SClient",
) {
App()
}
}

View File

@ -0,0 +1,12 @@
package su.sonoma.sclient
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
import kotlinx.browser.document
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
ComposeViewport(document.body!!) {
App()
}
}

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SClient</title>
<link type="text/css" rel="stylesheet" href="styles.css">
<script type="application/javascript" src="composeApp.js"></script>
</head>
<body>
</body>
</html>

View File

@ -0,0 +1,7 @@
html, body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}

8
client/gradle.properties Normal file
View File

@ -0,0 +1,8 @@
#Kotlin
kotlin.code.style=official
kotlin.daemon.jvmargs=-Xmx3072M
#Gradle
org.gradle.jvmargs=-Xmx3072M -Dfile.encoding=UTF-8
org.gradle.configuration-cache=true
org.gradle.caching=true

View File

@ -0,0 +1,21 @@
[versions]
androidx-lifecycle = "2.9.1"
composeHotReload = "1.0.0-alpha11"
composeMultiplatform = "1.8.2"
junit = "4.13.2"
kotlin = "2.2.0"
kotlinx-coroutines = "1.10.2"
[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
junit = { module = "junit:junit", version.ref = "junit" }
androidx-lifecycle-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
[plugins]
composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "composeHotReload" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }

BIN
client/gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

252
client/gradlew vendored Executable file
View File

@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
client/gradlew.bat vendored Normal file
View File

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@ -1,2 +0,0 @@
[virtualenvs]
in-project = true

View File

@ -1,36 +0,0 @@
[project]
name = "sonoma-app-client"
version = "0.1.0"
description = ""
requires-python = ">=3.12"
authors = [
{ name = "SaddyDEAD", email = "saddydead@sonoma.su" }
]
dependencies = [
"flet==0.28.3",
"cryptography",
"httpx (>=0.28.1,<0.29.0)"
]
[tool.flet]
org = "su.sonoma"
product = "SApp"
company = "Sonoma"
copyright = "Copyright (C) 2025 by Sonoma Org."
[tool.flet.app]
path = "src"
[tool.uv]
dev-dependencies = [
"flet[all]==0.28.3",
]
[tool.poetry]
package-mode = false
[tool.poetry.group.dev.dependencies]
flet = {extras = ["all"], version = "0.28.3"}

View File

@ -0,0 +1,35 @@
rootProject.name = "SClient"
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
pluginManagement {
repositories {
google {
mavenContent {
includeGroupAndSubgroups("androidx")
includeGroupAndSubgroups("com.android")
includeGroupAndSubgroups("com.google")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google {
mavenContent {
includeGroupAndSubgroups("androidx")
includeGroupAndSubgroups("com.android")
includeGroupAndSubgroups("com.google")
}
}
mavenCentral()
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
include(":composeApp")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

View File

@ -1,28 +0,0 @@
from cryptography.fernet import Fernet
import os
import httpx
KEY_FILE = ".key"
REMEMBER_FILE = ".auth_data"
def generate_key():
if not os.path.exists(KEY_FILE):
key = Fernet.generate_key()
with open(KEY_FILE, "wb") as f:
f.write(key)
async def load_fernet():
with open(KEY_FILE, "rb") as f:
key = f.read()
return Fernet(key)
async def login(name: str, password: str):
async with httpx.AsyncClient() as client:
r = await client.post(f'http://127.0.0.1:7535/login?name={name}&password={password}')
if r.is_success:
return True
else:
return False

View File

@ -1,102 +0,0 @@
import flet as ft
from auth.login import *
USERNAME = "admin"
PASSWORD = "1234"
def generate_key():
if not os.path.exists(KEY_FILE):
key = Fernet.generate_key()
with open(KEY_FILE, "wb") as f:
f.write(key)
async def load_fernet():
with open(KEY_FILE, "rb") as f:
key = f.read()
return Fernet(key)
async def main(page: ft.Page):
generate_key()
fernet = await load_fernet()
page.title = "SClient"
page.window_width = 400
page.window_height = 300
page.vertical_alignment = ft.MainAxisAlignment.CENTER
login_input = ft.TextField(label="Логин", autofocus=True)
password_input = ft.TextField(label="Пароль", password=True, can_reveal_password=True)
remember_checkbox = ft.Checkbox(label="Запомнить меня")
status_text = ft.Text(color=ft.Colors.RED)
if os.path.exists(REMEMBER_FILE):
try:
with open(REMEMBER_FILE, "rb") as f:
lines = f.readlines()
if len(lines) == 2:
saved_login = lines[0].decode().strip()
encrypted_pw = lines[1].strip()
decrypted_pw = fernet.decrypt(encrypted_pw).decode()
login_input.value = saved_login
password_input.value = decrypted_pw
remember_checkbox.value = True
except Exception as e:
print(f"[ОШИБКА] Не удалось прочитать данные: {e}")
async def login_click(e):
if await login(login_input.value, password_input.value):
if remember_checkbox.value:
encrypted_pw = fernet.encrypt(password_input.value.encode())
with open(REMEMBER_FILE, "wb") as f:
f.write(f"{login_input.value}\n".encode())
f.write(encrypted_pw)
else:
if os.path.exists(REMEMBER_FILE):
os.remove(REMEMBER_FILE)
page.clean()
page.add(
ft.Column(
[
ft.Text(f"Добро пожаловать, {USERNAME}!", size=24),
ft.ElevatedButton("Выйти", on_click=lambda e: page.go("/"))
],
alignment=ft.MainAxisAlignment.CENTER,
horizontal_alignment=ft.CrossAxisAlignment.CENTER
)
)
else:
status_text.value = "Неверный логин или пароль"
page.update()
page.add(
ft.Container(
content=ft.Column(
controls=[
ft.Image(
src="sonoma.png",
width=50,
height=50,
fit=ft.ImageFit.CONTAIN
),
ft.Text("SClient", size=24, weight=ft.FontWeight.BOLD),
login_input,
password_input,
remember_checkbox,
ft.ElevatedButton("Войти", on_click=login_click),
status_text
],
width=300,
alignment=ft.MainAxisAlignment.CENTER,
horizontal_alignment=ft.CrossAxisAlignment.CENTER
),
alignment=ft.alignment.center,
expand=True
)
)
ft.app(main)

View File

@ -11,7 +11,8 @@ dependencies = [
"fastapi (>=0.115.14,<0.116.0)",
"asyncpg (>=0.30.0,<0.31.0)",
"uvicorn (>=0.35.0,<0.36.0)",
"fastapi-users[sqlalchemy] (>=14.0.1,<15.0.0)"
"authx (>=1.4.3,<2.0.0)",
"pydantic[email] (>=2.11.7,<3.0.0)",
]

View File

@ -1,27 +0,0 @@
import uuid
from fastapi_users import FastAPIUsers, models
from fastapi_users.authentication import (
AuthenticationBackend,
BearerTransport,
JWTStrategy,
)
from fastapi_users.jwt import SecretType
from src.database.db import Database
from src.database.user import User
class Transport:
def __init__(self, secret: SecretType, db: Database):
self.secret = secret
self.db = db
self.bearer_transport = BearerTransport(tokenUrl="auth/jwt/login")
self.auth_backend = AuthenticationBackend(
name="jwt",
transport=self.bearer_transport,
get_strategy=self.get_jwt_strategy,
)
self.fastapi_users = FastAPIUsers[User, uuid.UUID](self.db.get_user_manager, [self.auth_backend])
self.current_active_user = self.fastapi_users.current_user(active=True)
def get_jwt_strategy(self) -> JWTStrategy[models.UP, models.ID]:
return JWTStrategy(secret=self.secret, lifetime_seconds=3600)

View File

@ -1,30 +0,0 @@
import uuid
from typing import Optional
from fastapi import Request
from fastapi_users import UUIDIDMixin, BaseUserManager
from fastapi_users.jwt import SecretType
from src.database.user import User
SECRET: SecretType
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
def __init__(self, secret: SecretType):
super().__init__()
reset_password_token_secret = SECRET
verification_token_secret = SECRET
async def on_after_register(self, user: User, request: Optional[Request] = None):
print(f"User {user.id} has registered.")
async def on_after_forgot_password(
self, user: User, token: str, request: Optional[Request] = None
):
print(f"User {user.id} has forgot their password. Reset token: {token}")
async def on_after_request_verify(
self, user: User, token: str, request: Optional[Request] = None
):
print(f"Verification requested for user {user.id}. Verification token: {token}")

View File

@ -1,40 +1,80 @@
from collections.abc import AsyncGenerator
from fastapi import Depends
from fastapi_users.db import SQLAlchemyUserDatabase
from fastapi_users.jwt import SecretType
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
import asyncpg
from email_validator import EmailNotValidError, validate_email
from pydantic import EmailStr
from src.auth.user_manager import UserManager
from src.database.user import User, Base
from src.database.user import UserLogin, User
class Database:
def __init__(
self,
admin_name: str,
admin_password: str,
db_user: str,
db_pass: str,
db_host: str,
db_port: int,
db_name: str,
secret: SecretType
db_name: str
):
self.DATABASE_URL = f'postgresql+asyncpg://{db_user}:{db_pass}@{db_host}:{db_port}/{db_name}'
self.engine = create_async_engine(self.DATABASE_URL)
self.async_session_maker = async_sessionmaker(self.engine, expire_on_commit=False)
self.secret = secret
self.admin_name = admin_name
self.admin_password = admin_password
self.db_user = db_user
self.db_pass = db_pass
self.db_host = db_host
self.db_name = db_name
async def create_db_and_tables(self):
async with self.engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def connect(self):
try:
self.conn =await asyncpg.create_pool(
user=self.db_user,
password=self.db_pass,
database=self.db_name,
host=self.db_host
)
except Exception as e:
return e
async def disconnect(self):
await self.conn.close()
async def init(self):
try:
await self.conn.execute(
'''
CREATE TABLE IF NOT EXISTS users (
username TEXT NOT NULL PRIMARY KEY UNIQUE,
password TEXT NOT NULL,
email TEXT NOT NULL,
role TEXT NOT NULL,
out_vpn_access BOOL NOT NULL,
docker_access BOOL NOT NULL,
git_access BOOL NOT NULL
)
'''
)
await self.conn.fetch(
'''
INSERT INTO users (username, password, email, role, out_vpn_access, docker_access, git_access)
SELECT $1, $2, 'admin@admin.admin', 'Admin', true, true, true
WHERE NOT EXISTS (SELECT 1 FROM users WHERE username = $1)
''',
self.admin_name,
self.admin_password
)
except Exception as e:
return e
async def get_async_session(self) -> AsyncGenerator[AsyncSession, None]:
async with self.async_session_maker() as session:
yield session
async def get_user(self, username: str):
fetch = await self.conn.fetchrow('SELECT * FROM users WHERE username = $1', username)
if fetch is None:
return None
n = list()
for i in fetch.values():
n.append(i)
return n
async def get_user_db(self, session: AsyncSession = Depends(get_async_session)):
yield SQLAlchemyUserDatabase(session, User)
async def get_user_manager(self, user_db: SQLAlchemyUserDatabase = Depends(get_user_db)):
yield UserManager(self.secret, user_db)

View File

@ -1,15 +0,0 @@
import uuid
from fastapi_users import schemas
class UserRead(schemas.BaseUser[uuid.UUID]):
pass
class UserCreate(schemas.BaseUserCreate):
pass
class UserUpdate(schemas.BaseUserUpdate):
pass

View File

@ -1,13 +1,16 @@
from fastapi_users_db_sqlalchemy import SQLAlchemyBaseUserTableUUID
from sqlalchemy.orm import DeclarativeBase, Mapped
from pydantic import BaseModel, EmailStr
from src.database.role import Role
class Base(DeclarativeBase):
pass
class UserLogin(BaseModel):
username: str
password: str
class User(SQLAlchemyBaseUserTableUUID, Base):
username: Mapped[str]
role: Mapped[Role]
vpn_server_access: Mapped[bool]
main_server_access: Mapped[bool]
class User(UserLogin):
username: str
email: EmailStr
role: Role
out_vpn_access: bool
docker_access: bool
git_access: bool
password: str

View File

@ -1,13 +1,13 @@
import asyncio
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import RedirectResponse
from database.db import Database
import uvicorn
from src.database.schemas import *
from src.auth.transport import Transport
from src.database.user import User
from contextlib import asynccontextmanager
app = FastAPI(title='sclient-main-server')
from authx import AuthXConfig, AuthX, RequestToken
from fastapi import Response, FastAPI, Request, HTTPException, Depends
from fastapi.responses import RedirectResponse
import uvicorn
from src.database.db import Database
from src.database.user import User, UserLogin
### Settings
# TODO: Create .env
@ -18,74 +18,80 @@ ADMIN_PASSWORD = 'admin'
DATABASE_USER = 'ADMIN'
DATABASE_PASS = '123123'
DATABASE_HOST = '127.0.0.1'
DATABASE_PORT = 5432
DATABASE_NAME = 'sonoma-db'
SECRET = 'SECRET'
###
@asynccontextmanager
async def lifespan(app: FastAPI):
await db.connect()
await db.init()
yield
await db.disconnect()
db = Database(
ADMIN_NAME,
ADMIN_PASSWORD,
DATABASE_USER,
DATABASE_PASS,
DATABASE_HOST,
DATABASE_PORT,
DATABASE_NAME,
SECRET
DATABASE_NAME
)
transport = Transport(SECRET, db)
app = FastAPI(title='sclient-main-server', lifespan=lifespan)
config = AuthXConfig()
config.JWT_SECRET_KEY = SECRET
config.JWT_ACCESS_COOKIE_NAME = "sclient_access_token"
config.JWT_TOKEN_LOCATION = ["cookies"]
security = AuthX(config=config)
security.handle_errors(app)
class App:
def init(self, loop) -> None:
config = uvicorn.Config(
app,
loop=loop,
host='0.0.0.0',
port=PORT
)
server = uvicorn.Server(config)
loop.run_until_complete(server.serve())
@app.get('/')
async def docs(self: Request):
return RedirectResponse(f'{self.url}docs')
@app.get("/authenticated-route")
async def authenticated_route(user: User = Depends(transport.current_active_user)):
return {"message": f"Hello {user.email}!"}
### Auth
@app.post('/login')
async def login(self: Request, credentials: UserLogin, response: Response):
user = await db.get_user(credentials.username)
if user is not None:
if user[1] == credentials.password:
token = security.create_access_token(uid=credentials.username)
response.set_cookie(config.JWT_ACCESS_COOKIE_NAME, token)
return {
"access_token": token
}
raise HTTPException(
401,
detail='Incorrect username or password'
)
###
### Protected
@app.get('/protected/auth', dependencies=[Depends(security.access_token_required)])
async def auth(self: Request):
try:
return {"message": "Hello world !"}
except Exception as e:
raise HTTPException(
401,
detail={"message": str(e)}
) from e
###
def main():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
server = App()
app.include_router(
transport.fastapi_users.get_auth_router(transport.auth_backend), prefix="/auth/jwt", tags=["auth"]
)
app.include_router(
transport.fastapi_users.get_register_router(UserRead, UserCreate),
prefix="/auth",
tags=["auth"],
)
app.include_router(
transport.fastapi_users.get_reset_password_router(),
prefix="/auth",
tags=["auth"],
)
app.include_router(
transport.fastapi_users.get_verify_router(UserRead),
prefix="/auth",
tags=["auth"],
)
app.include_router(
transport.fastapi_users.get_users_router(UserRead, UserUpdate),
prefix="/users",
tags=["users"],
)
loop.run_until_complete(db.create_db_and_tables())
server.init(loop)
uvicorn.run(app, host='0.0.0.0', port=PORT)
if __name__ == '__main__':
main()