├── .gitignore ├── Cargo.toml ├── README.md ├── aarch64-skyline-switch.json └── src ├── dialog.rs ├── dialog_ok.rs ├── lib.rs ├── session.rs └── templates ├── dialog.html └── dialog_ok.html /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "skyline-web" 3 | description = "Utility crate to generate web pages and open the web browser for game modding on Nintendo Switch" 4 | license = "MIT" 5 | version = "0.2.0" 6 | authors = ["jam1garner ", "Raytwo "] 7 | edition = "2018" 8 | 9 | [dependencies] 10 | skyline = { git = "https://github.com/ultimate-research/skyline-rs" } 11 | nnsdk = { git = "https://github.com/ultimate-research/nnsdk-rs" } 12 | ramhorns = "0.9.4" 13 | 14 | # web session json 15 | serde = { version = "1", optional = true } 16 | serde_json = { version = "1", optional = true } 17 | 18 | [features] 19 | default = ["json"] 20 | json = ["serde", "serde_json"] 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # skyline-web 2 | 3 | A Rust library for working with the Nintendo Switch browser designed around use in game mods. 4 | -------------------------------------------------------------------------------- /aarch64-skyline-switch.json: -------------------------------------------------------------------------------- 1 | { 2 | "abi-blacklist": [ 3 | "stdcall", 4 | "fastcall", 5 | "vectorcall", 6 | "thiscall", 7 | "win64", 8 | "sysv64" 9 | ], 10 | "arch": "aarch64", 11 | "crt-static-default": false, 12 | "crt-static-respected": false, 13 | "data-layout": "e-m:e-i8:8:32-i16:16:32-i64:64-i128:128-n32:64-S128", 14 | "dynamic-linking": true, 15 | "dynamic-linking-available": true, 16 | "executables": true, 17 | "has-elf-tls": false, 18 | "has-rpath": false, 19 | "linker": "rust-lld", 20 | "linker-flavor": "ld.lld", 21 | "llvm-target": "aarch64-unknown-none", 22 | "max-atomic-width": 128, 23 | "os": "switch", 24 | "panic-strategy": "abort", 25 | "position-independent-executables": true, 26 | "pre-link-args": { 27 | "ld.lld": [ 28 | "-Tlink.T", 29 | "-init=__custom_init", 30 | "-fini=__custom_fini", 31 | "--export-dynamic" 32 | ] 33 | }, 34 | "post-link-args": { 35 | "ld.lld": [ 36 | "--no-gc-sections", 37 | "--eh-frame-hdr" 38 | ] 39 | }, 40 | "relro-level": "off", 41 | "target-c-int-width": "32", 42 | "target-endian": "little", 43 | "target-pointer-width": "64", 44 | "vendor": "roblabla" 45 | } 46 | -------------------------------------------------------------------------------- /src/dialog.rs: -------------------------------------------------------------------------------- 1 | use crate::{Background, BootDisplay, Webpage}; 2 | use ramhorns::{Template, Content}; 3 | 4 | #[derive(Content)] 5 | pub struct Dialog { 6 | #[md] 7 | text: String, 8 | left_button: String, 9 | right_button: String, 10 | } 11 | 12 | #[derive(Debug, Copy, Clone, PartialEq)] 13 | pub enum DialogOption { 14 | Left, 15 | Right, 16 | Default 17 | } 18 | 19 | impl Dialog { 20 | pub fn new(text: S1, left_button: S2, right_button: S3) -> Self 21 | where S1: Into, 22 | S2: Into, 23 | S3: Into, 24 | { 25 | Self { 26 | text: text.into(), 27 | left_button: left_button.into(), 28 | right_button: right_button.into() 29 | } 30 | } 31 | 32 | pub fn no_yes>(message: S) -> bool { 33 | match Dialog::new(message, "No", "Yes").show() { 34 | DialogOption::Left => false, 35 | DialogOption::Right => true, 36 | DialogOption::Default => false 37 | } 38 | } 39 | 40 | pub fn yes_no>(message: S) -> bool { 41 | match Dialog::new(message, "Yes", "No").show() { 42 | DialogOption::Left => true, 43 | DialogOption::Right => false, 44 | DialogOption::Default => false 45 | } 46 | } 47 | 48 | pub fn ok_cancel>(message: S) -> bool { 49 | match Dialog::new(message, "Ok", "Cancel").show() { 50 | DialogOption::Left => true, 51 | DialogOption::Right => false, 52 | DialogOption::Default => false 53 | } 54 | } 55 | 56 | pub fn show(&self) -> DialogOption { 57 | let tpl = Template::new(include_str!("templates/dialog.html")).unwrap(); 58 | 59 | let response = Webpage::new() 60 | .background(Background::BlurredScreenshot) 61 | .file("index.html", &tpl.render(self)) 62 | .boot_display(BootDisplay::BlurredScreenshot) 63 | .open() 64 | .unwrap(); 65 | 66 | match response.get_last_url().unwrap() { 67 | "http://localhost/left" => DialogOption::Left, 68 | "http://localhost/right" => DialogOption::Right, 69 | // Until this is reworked to offer a default option on forceful closure 70 | _ => DialogOption::Default 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/dialog_ok.rs: -------------------------------------------------------------------------------- 1 | use crate::{Background, BootDisplay, Webpage}; 2 | use ramhorns::{Template, Content}; 3 | 4 | #[derive(Content)] 5 | pub struct DialogOk { 6 | #[md] 7 | text: String, 8 | ok_button: String, 9 | } 10 | 11 | #[derive(Debug, Copy, Clone, PartialEq)] 12 | pub enum DialogOption { 13 | Ok, 14 | } 15 | 16 | impl DialogOk { 17 | pub fn new(text: S1, ok_button: S2) -> Self 18 | where S1: Into, 19 | S2: Into, 20 | { 21 | Self { 22 | text: text.into(), 23 | ok_button: ok_button.into(), 24 | } 25 | } 26 | 27 | pub fn ok>(message: S) -> bool { 28 | match DialogOk::new(message, "OK").show() { 29 | DialogOption::Ok => true, 30 | } 31 | } 32 | 33 | pub fn show(&self) -> DialogOption { 34 | let tpl = Template::new(include_str!("templates/dialog_ok.html")).unwrap(); 35 | 36 | let response = Webpage::new() 37 | .background(Background::BlurredScreenshot) 38 | .file("index.html", &tpl.render(self)) 39 | .boot_display(BootDisplay::BlurredScreenshot) 40 | .open() 41 | .unwrap(); 42 | 43 | DialogOption::Ok 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![feature(new_uninit)] 2 | #![feature(new_zeroed_alloc)] 3 | 4 | use std::collections::HashMap; 5 | use std::ffi::CStr; 6 | use std::fs; 7 | use std::num::NonZeroU32; 8 | use std::path::Path; 9 | use std::str::Utf8Error; 10 | 11 | use skyline::info::get_program_id; 12 | 13 | use nnsdk::web::offlinewebsession::*; 14 | use nnsdk::web::*; 15 | use skyline::nn::os::{SystemEventClearMode, SystemEventType, TryWaitSystemEvent}; 16 | 17 | pub use nnsdk::web::{ 18 | offlinewebsession::OfflineWebSession, OfflineBackgroundKind as Background, 19 | OfflineBootDisplayKind as BootDisplay, WebSessionBootMode as Visibility, 20 | }; 21 | 22 | mod session; 23 | pub use session::WebSession; 24 | 25 | pub struct PageResult { 26 | ret: Box, 27 | } 28 | 29 | impl PageResult { 30 | pub fn new() -> Self { 31 | let mut ret; 32 | unsafe { 33 | ret = Box::::new_zeroed().assume_init(); 34 | 35 | OfflineHtmlPageReturnValue(ret.as_mut()); 36 | } 37 | 38 | PageResult { ret } 39 | } 40 | 41 | pub fn get_last_url(&self) -> Result<&str, Utf8Error> { 42 | unsafe { 43 | let last_url = GetLastUrl(self.ret.as_ref()); 44 | CStr::from_ptr(last_url as _).to_str() 45 | } 46 | } 47 | 48 | pub fn get_exit_reason(&self) -> OfflineExitReason { 49 | self.ret.get_exit_reason() 50 | } 51 | } 52 | 53 | impl AsRef for PageResult { 54 | fn as_ref(&self) -> &OfflineHtmlPageReturnValue { 55 | &self.ret 56 | } 57 | } 58 | 59 | impl AsMut for PageResult { 60 | fn as_mut(&mut self) -> &mut OfflineHtmlPageReturnValue { 61 | &mut self.ret 62 | } 63 | } 64 | 65 | pub struct Webpage<'a> { 66 | files: HashMap<&'a str, &'a [u8]>, 67 | dir: Option<&'a Path>, 68 | show: Option<&'a str>, 69 | htdocs_dir: Option<&'a str>, 70 | background: OfflineBackgroundKind, 71 | boot_display: OfflineBootDisplayKind, 72 | javascript: bool, 73 | footer: bool, 74 | pointer: bool, 75 | boot_icon: bool, 76 | web_audio: bool, 77 | } 78 | 79 | impl<'a> Default for Webpage<'a> { 80 | fn default() -> Self { 81 | Self { 82 | files: HashMap::new(), 83 | dir: None, 84 | show: None, 85 | htdocs_dir: None, 86 | background: OfflineBackgroundKind::Default, 87 | boot_display: OfflineBootDisplayKind::Default, 88 | javascript: true, 89 | footer: false, 90 | pointer: false, 91 | boot_icon: false, 92 | web_audio: true, 93 | } 94 | } 95 | } 96 | 97 | #[repr(transparent)] 98 | #[derive(Debug, Copy, Clone)] 99 | pub struct OsError(NonZeroU32); 100 | 101 | impl<'a> Webpage<'a> { 102 | pub fn new() -> Self { 103 | Self::default() 104 | } 105 | 106 | /// Add a single file to the context of the webpage 107 | pub fn file(&mut self, name: &'a S, data: &'a D) -> &mut Self 108 | where 109 | S: AsRef + ?Sized + 'a, 110 | D: AsRef<[u8]> + ?Sized + 'a, 111 | { 112 | self.files.insert(name.as_ref(), data.as_ref()); 113 | 114 | self 115 | } 116 | 117 | pub fn files(&mut self, files: &'a Arr) -> &mut Self 118 | where 119 | Str: AsRef + Sized + 'a, 120 | Data: AsRef<[u8]> + Sized + 'a, 121 | Arr: AsRef<[(Str, Data)]> + ?Sized + 'a, 122 | { 123 | for (name, data) in files.as_ref().into_iter() { 124 | self.files.insert(name.as_ref(), data.as_ref()); 125 | } 126 | 127 | self 128 | } 129 | 130 | pub fn with_dir

(&mut self, dir_path: &'a P) -> &mut Self 131 | where 132 | P: AsRef + ?Sized + 'a, 133 | { 134 | self.dir = Some(dir_path.as_ref()); 135 | 136 | self 137 | } 138 | 139 | pub fn background(&mut self, bg: OfflineBackgroundKind) -> &mut Self { 140 | self.background = bg; 141 | 142 | self 143 | } 144 | 145 | pub fn boot_display(&mut self, boot: OfflineBootDisplayKind) -> &mut Self { 146 | self.boot_display = boot; 147 | 148 | self 149 | } 150 | 151 | pub fn javascript(&mut self, js: bool) -> &mut Self { 152 | self.javascript = js; 153 | 154 | self 155 | } 156 | 157 | pub fn footer(&mut self, footer: bool) -> &mut Self { 158 | self.footer = footer; 159 | 160 | self 161 | } 162 | 163 | pub fn pointer(&mut self, pointer: bool) -> &mut Self { 164 | self.pointer = pointer; 165 | 166 | self 167 | } 168 | 169 | pub fn boot_icon(&mut self, boot_icon: bool) -> &mut Self { 170 | self.boot_icon = boot_icon; 171 | 172 | self 173 | } 174 | 175 | pub fn web_audio(&mut self, audio: bool) -> &mut Self { 176 | self.web_audio = audio; 177 | 178 | self 179 | } 180 | 181 | pub fn start_page(&mut self, path: &'a S) -> &mut Self 182 | where 183 | S: AsRef + ?Sized + 'a, 184 | { 185 | self.show = Some(path.as_ref()); 186 | 187 | self 188 | } 189 | 190 | pub fn htdocs_dir(&mut self, path: &'a S) -> &mut Self 191 | where 192 | S: AsRef + ?Sized + 'a, 193 | { 194 | self.htdocs_dir = Some(path.as_ref()); 195 | 196 | self 197 | } 198 | 199 | fn into_page_args(&mut self) -> Result, OsError> { 200 | let program_id = get_program_id(); 201 | 202 | let htdocs_dir = self.htdocs_dir.unwrap_or("temp"); 203 | 204 | let folder_path = Path::new("sd:/atmosphere/contents") 205 | .join(&format!("{:016X}", program_id)) 206 | .join(&format!("manual_html/html-document/{}.htdocs/", htdocs_dir)); 207 | 208 | if let Some(dir) = self.dir { 209 | // Copy dir to temp.htdocs 210 | } else if !folder_path.exists() { 211 | let _ = fs::create_dir_all(&folder_path); 212 | } 213 | 214 | for (path, data) in self.files.iter() { 215 | let file_path = folder_path.join(path); 216 | let file_parent = file_path.parent().unwrap(); 217 | if !file_parent.exists() { 218 | fs::create_dir_all(file_parent).unwrap(); 219 | } 220 | fs::write(file_path, data).unwrap(); 221 | } 222 | 223 | let mut args = new_boxed_html_page_arg(format!( 224 | "{}.htdocs/{}", 225 | htdocs_dir, 226 | self.show.unwrap_or("index.html") 227 | )); 228 | 229 | args.set_background_kind(self.background); 230 | args.set_boot_display_kind(self.boot_display); 231 | args.enable_javascript(self.javascript); 232 | args.display_footer(self.footer); 233 | args.enable_pointer(self.pointer); 234 | args.enable_boot_loading_icon(self.boot_icon); 235 | args.enable_web_audio(self.web_audio); 236 | 237 | Ok(args) 238 | } 239 | 240 | pub fn open_session(&mut self, boot_mode: Visibility) -> Result { 241 | self.javascript(true); 242 | 243 | let mut args = self.into_page_args()?; 244 | args.set_boot_mode(boot_mode); 245 | 246 | let session = OfflineWebSession::new(); 247 | let system_evt = SystemEventType::new(SystemEventClearMode::Manual); 248 | 249 | unsafe { 250 | Start(&session, &&system_evt, &args); 251 | TryWaitSystemEvent(&system_evt); 252 | } 253 | 254 | Ok(WebSession(session)) 255 | } 256 | 257 | pub fn open(&mut self) -> Result { 258 | let mut args = self.into_page_args().unwrap(); 259 | let mut page_result = PageResult::new(); 260 | 261 | let result = unsafe { ShowOfflineHtmlPage(page_result.as_mut(), args.as_mut()) }; 262 | 263 | match result { 264 | 0 => Ok(page_result), 265 | err => Err(OsError(NonZeroU32::new(err).unwrap())), 266 | } 267 | } 268 | } 269 | 270 | fn new_boxed_html_page_arg(page_path: T) -> Box 271 | where 272 | T: AsRef<[u8]>, 273 | { 274 | let mut path_bytes = page_path.as_ref().to_vec(); 275 | 276 | if path_bytes.len() > 3072 { 277 | path_bytes.truncate(3071); 278 | } 279 | 280 | path_bytes.push(b'\0'); 281 | 282 | unsafe { 283 | let mut instance = Box::::new_zeroed().assume_init(); 284 | ShowOfflineHtmlPageArg(instance.as_mut(), path_bytes.as_ptr()); 285 | instance 286 | } 287 | } 288 | 289 | pub mod dialog; 290 | pub mod dialog_ok; 291 | pub extern crate ramhorns; 292 | pub use ramhorns::*; 293 | -------------------------------------------------------------------------------- /src/session.rs: -------------------------------------------------------------------------------- 1 | use nnsdk::web::offlinewebsession::*; 2 | use std::ffi::CString; 3 | 4 | use crate::PageResult; 5 | 6 | pub use nnsdk::web::{ 7 | offlinewebsession::OfflineWebSession, OfflineBackgroundKind as Background, 8 | OfflineBootDisplayKind as BootDisplay, WebSessionBootMode as Visibility, 9 | }; 10 | 11 | extern "C" { 12 | #[link_name = "\u{1}_ZN2nn3web17OfflineWebSession11RequestExitEv"] 13 | pub fn request_exit(session: &OfflineWebSession); 14 | } 15 | 16 | pub struct WebSession(pub(crate) OfflineWebSession); 17 | 18 | impl WebSession { 19 | /// Sends a message, blocking until it succeeds 20 | pub fn send(&self, message: &str) { 21 | let len = message.len() + 1; 22 | let message = CString::new(message).unwrap(); 23 | 24 | while unsafe { !TrySendContentMessage(&self.0, message.as_ptr() as _, len) } {} 25 | } 26 | 27 | /// Attempts to send a message, returning true if it succeeds 28 | pub fn try_send(&self, message: &str) -> bool { 29 | let len = message.len() + 1; 30 | let message = CString::new(message).unwrap(); 31 | 32 | unsafe { TrySendContentMessage(&self.0, message.as_ptr() as _, len) } 33 | } 34 | 35 | /// Blocks until a message is recieved 36 | /// 37 | /// Up to 4 KiB in size, for larger or more efficient sizes use `recv_max` 38 | pub fn recv(&self) -> String { 39 | self.recv_max(0x10000) 40 | } 41 | 42 | /// Blocks until a message is recieved, up to `max_size` bytes 43 | pub fn recv_max(&self, max_size: usize) -> String { 44 | let mut buffer = vec![0u8; max_size]; 45 | 46 | loop { 47 | if let Some(size) = self.inner_recv(&mut buffer) { 48 | if size != 0 { 49 | buffer.truncate(size - 1); 50 | buffer.shrink_to_fit(); 51 | let message = String::from_utf8(buffer).map(|string| string).unwrap(); 52 | 53 | break message; 54 | } 55 | } 56 | } 57 | } 58 | 59 | /// Attempts to recieve a message without blocking 60 | /// 61 | /// Up to 4 KiB in size, for larger or more efficient sizes use `try_recv_max` 62 | pub fn try_recv(&self) -> Option { 63 | self.try_recv_max(0x10000) 64 | } 65 | 66 | /// Attempts to recieve a message without blocking, up to `max_size` bytes 67 | pub fn try_recv_max(&self, max_size: usize) -> Option { 68 | let mut buffer = vec![0u8; max_size]; 69 | 70 | self.inner_recv(&mut buffer) 71 | .map(|size| { 72 | if size != 0 { 73 | buffer.truncate(size - 1); 74 | buffer.shrink_to_fit(); 75 | String::from_utf8(buffer).map(|string| string).ok() 76 | } else { 77 | None 78 | } 79 | }) 80 | .flatten() 81 | } 82 | 83 | fn inner_recv>(&self, buffer: &mut T) -> Option { 84 | let buffer = buffer.as_mut(); 85 | let mut out_size = 0; 86 | 87 | unsafe { 88 | if skyline::nn::web::offlinewebsession::TryReceiveContentMessage( 89 | &self.0, 90 | &mut out_size, 91 | buffer.as_mut_ptr(), 92 | buffer.len(), 93 | ) != false 94 | { 95 | Some(out_size) 96 | } else { 97 | None 98 | } 99 | } 100 | } 101 | 102 | /// Show a previously hidden web session 103 | pub fn show(&self) { 104 | unsafe { Appear(&self.0) }; 105 | } 106 | 107 | /// Wait until the page has been exited 108 | pub fn wait_for_exit(&self) -> PageResult { 109 | let return_value = PageResult::new(); 110 | unsafe { WaitForExit(&self.0, return_value.as_ref()) }; 111 | return_value 112 | } 113 | 114 | // Exit the browser forcefully 115 | pub fn exit(&self) { 116 | unsafe { request_exit(&self.0); } 117 | } 118 | } 119 | 120 | 121 | 122 | #[cfg(feature = "json")] 123 | use serde::{de::DeserializeOwned, Serialize}; 124 | 125 | #[cfg(feature = "json")] 126 | impl WebSession { 127 | /// Send a type as a JSON value, blocking until it sends 128 | pub fn send_json(&self, obj: &T) { 129 | self.send(&serde_json::to_string(obj).unwrap()) 130 | } 131 | 132 | /// Attempt to send a type as a JSON value, returning false if it doesn't succeed 133 | pub fn try_send_json(&self, obj: &T) -> bool { 134 | self.try_send(&serde_json::to_string(obj).unwrap()) 135 | } 136 | 137 | /// Receive a given type as a JSON message, blocking until one is ready 138 | pub fn recv_json(&self) -> serde_json::Result { 139 | serde_json::from_str(&self.recv()) 140 | } 141 | 142 | /// Receive a given type as a JSON message, returning None if a message is not ready 143 | pub fn try_recv_json(&self) -> Option> { 144 | self.try_recv().map(|msg| serde_json::from_str(&msg)) 145 | } 146 | 147 | /// Receive a given type as a JSON message, blocking until one is ready, setting a custom max 148 | /// payload size. 149 | pub fn recv_json_max(&self, max_size: usize) -> serde_json::Result { 150 | serde_json::from_str(&self.recv_max(max_size)) 151 | } 152 | 153 | /// Receive a given type as a JSON message, returning None if a message is not ready, with a 154 | /// max message size of `max_size` 155 | pub fn try_recv_json_max( 156 | &self, 157 | max_size: usize, 158 | ) -> Option> { 159 | self.try_recv_max(max_size) 160 | .map(|msg| serde_json::from_str(&msg)) 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/templates/dialog.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Document 7 | 8 | 9 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 86 | 91 | 92 |
{{ text }}
82 | 85 | 87 | 90 |
93 | 94 | 95 | -------------------------------------------------------------------------------- /src/templates/dialog_ok.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Document 7 | 8 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 85 | 86 |
{{ text }}
81 | 84 |
87 | 88 | 89 | --------------------------------------------------------------------------------