Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 18bc607d93 | |||
| 54216aa133 | |||
| 8eb5c1a3b2 | |||
| 406d73c10f | |||
| 6fac7e320b | |||
| a32940a3f3 | |||
| 10180b7109 | |||
| dd6b3fa70d | |||
| a8786b4c1c | |||
| 207a849f06 | |||
| 79565f2ef3 |
@@ -1,116 +1,20 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
# Build output
|
||||
/build/
|
||||
/out/
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
# CEF binaries
|
||||
/thirdparty/cef/
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
# Visual Studio
|
||||
.vs/
|
||||
*.vcxproj.user
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
# CMake
|
||||
CMakeCache.txt
|
||||
CMakeFiles/
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-temporary-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (http://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
.env.test
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
|
||||
# Mac files
|
||||
# macOS
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Electron build output
|
||||
/dist
|
||||
/out
|
||||
/release
|
||||
/build
|
||||
*.nupkg
|
||||
*.AppImage
|
||||
*.dmg
|
||||
*.exe
|
||||
*.pkg
|
||||
|
||||
# IDE config files
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
site-history.json
|
||||
bookmarks.json
|
||||
bookmarks.backup.json
|
||||
search-history.json
|
||||
|
||||
# Portable user data folder
|
||||
user-data/
|
||||
|
||||
# AppImage / SteamOS
|
||||
squashfs-root/
|
||||
*.AppImage
|
||||
|
||||
# Electron build output
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
release/
|
||||
|
||||
# Native binaries
|
||||
nebula
|
||||
nebula.exe
|
||||
|
||||
# Node/Electron
|
||||
node_modules/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Build artifacts
|
||||
nebula-appdir/
|
||||
*.asar
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
cmake_minimum_required(VERSION 3.21)
|
||||
|
||||
project(NebulaBrowser LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# CEF location
|
||||
# ------------------------------------------------------------
|
||||
|
||||
set(CEF_ROOT "${CMAKE_SOURCE_DIR}/thirdparty/cef" CACHE PATH "Path to the CEF binary distribution")
|
||||
|
||||
if(NOT EXISTS "${CEF_ROOT}/cmake/FindCEF.cmake")
|
||||
message(FATAL_ERROR
|
||||
"CEF was not found.\n"
|
||||
"Expected CEF here:\n"
|
||||
" ${CEF_ROOT}\n\n"
|
||||
"Make sure the contents of the CEF binary distribution are inside thirdparty/cef."
|
||||
)
|
||||
endif()
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# CEF setup
|
||||
# ------------------------------------------------------------
|
||||
|
||||
list(APPEND CMAKE_MODULE_PATH "${CEF_ROOT}/cmake")
|
||||
|
||||
find_package(CEF REQUIRED)
|
||||
|
||||
add_subdirectory(
|
||||
"${CEF_LIBCEF_DLL_WRAPPER_PATH}"
|
||||
"${CMAKE_BINARY_DIR}/libcef_dll_wrapper"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Nebula source files
|
||||
# ------------------------------------------------------------
|
||||
|
||||
set(NEBULA_SOURCES
|
||||
app/main.cpp
|
||||
src/app/nebula_controller.cpp
|
||||
src/app/run.cpp
|
||||
src/browser/session_state.cpp
|
||||
src/browser/tab.cpp
|
||||
src/browser/tab_manager.cpp
|
||||
src/browser/url_utils.cpp
|
||||
src/cef/browser_client.cpp
|
||||
src/cef/nebula_app.cpp
|
||||
src/ui/paths.cpp
|
||||
src/window/nebula_window.cpp
|
||||
)
|
||||
|
||||
add_executable(NebulaBrowser WIN32
|
||||
${NEBULA_SOURCES}
|
||||
)
|
||||
|
||||
SET_EXECUTABLE_TARGET_PROPERTIES(NebulaBrowser)
|
||||
|
||||
if(MSVC)
|
||||
set_property(TARGET NebulaBrowser PROPERTY
|
||||
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>"
|
||||
)
|
||||
endif()
|
||||
|
||||
target_include_directories(NebulaBrowser PRIVATE
|
||||
"${CMAKE_SOURCE_DIR}/src"
|
||||
"${CEF_ROOT}"
|
||||
"${CEF_ROOT}/include"
|
||||
)
|
||||
|
||||
target_link_libraries(NebulaBrowser PRIVATE
|
||||
libcef_dll_wrapper
|
||||
${CEF_STANDARD_LIBS}
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Platform-specific CEF linking
|
||||
# ------------------------------------------------------------
|
||||
|
||||
if(WIN32)
|
||||
target_link_libraries(NebulaBrowser PRIVATE
|
||||
"${CEF_ROOT}/Release/libcef.lib"
|
||||
dwmapi
|
||||
)
|
||||
|
||||
target_compile_definitions(NebulaBrowser PRIVATE
|
||||
NOMINMAX
|
||||
WIN32_LEAN_AND_MEAN
|
||||
)
|
||||
endif()
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Copy CEF runtime files after build
|
||||
# ------------------------------------------------------------
|
||||
|
||||
if(WIN32)
|
||||
add_custom_command(TARGET NebulaBrowser POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_directory
|
||||
"${CEF_ROOT}/Release"
|
||||
"$<TARGET_FILE_DIR:NebulaBrowser>"
|
||||
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_directory
|
||||
"${CEF_ROOT}/Resources"
|
||||
"$<TARGET_FILE_DIR:NebulaBrowser>"
|
||||
|
||||
COMMENT "Copying CEF runtime files..."
|
||||
)
|
||||
endif()
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Copy Nebula UI files after build
|
||||
# ------------------------------------------------------------
|
||||
|
||||
add_custom_command(TARGET NebulaBrowser POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_directory
|
||||
"${CMAKE_SOURCE_DIR}/ui"
|
||||
"$<TARGET_FILE_DIR:NebulaBrowser>/ui"
|
||||
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_directory
|
||||
"${CMAKE_SOURCE_DIR}/assets"
|
||||
"$<TARGET_FILE_DIR:NebulaBrowser>/ui/assets"
|
||||
|
||||
COMMENT "Copying Nebula UI files and assets..."
|
||||
)
|
||||
@@ -1,184 +0,0 @@
|
||||
# NEBULA BROWSER
|
||||
|
||||
*A controller-first browser, now re-focused as a Linux-first project*
|
||||
|
||||
---
|
||||
|
||||
### Limited Development • Project in Maintenance Mode
|
||||
|
||||
Nebula Browser is no longer under active, full-time development.
|
||||
The project is maintained on an occasional basis, with updates made when time and interest allow.
|
||||
|
||||
---
|
||||
|
||||
# Nebula
|
||||
|
||||

|
||||
|
||||
Nebula is a customizable and privacy-focused web browser built with Electron. It is designed to be lightweight, secure, and user-friendly, with a strong emphasis on **controller-first and keyboard-driven interaction**, performance, and privacy.
|
||||
|
||||
While originally conceived for SteamOS and the Steam Deck, Nebula has since evolved into a **Linux-first experimental browser**, aimed at alternative input setups, handheld PCs, accessibility use cases, and living room environments.
|
||||
|
||||
---
|
||||
|
||||
## Project Status
|
||||
|
||||
**Status:** Maintenance Mode
|
||||
**Maintenance:** Occasional updates
|
||||
**Development:** No active roadmap
|
||||
**Focus:** Linux-first (desktop, handhelds, and alternative input setups)
|
||||
|
||||
Nebula is not a primary or actively scheduled project. Updates may occur sporadically and are not guaranteed. The repository remains open for use, modification, and experimentation under the MIT license.
|
||||
|
||||
This repository reflects a stable snapshot of the project, with incremental improvements added when appropriate.
|
||||
|
||||
---
|
||||
|
||||
## Why the Project Changed Direction
|
||||
|
||||
Nebula was originally created with a very specific goal: to be a **controller-first browser designed to live inside the Steam ecosystem**, especially for Steam Deck and SteamOS users who wanted a seamless web experience without relying on desktop mode, keyboards, or external workarounds.
|
||||
|
||||
During the Steam review process, Valve determined that Nebula does not fit within Steam’s allowed categories for non-game software. As a result, the browser could not be distributed on the Steam Store.
|
||||
|
||||
At the time, Steam distribution was considered a core pillar of the project, and development was paused as the original vision could no longer be fulfilled in its intended form.
|
||||
|
||||
Since then, community feedback has highlighted broader interest in Nebula’s **input model and interaction design**, beyond Steam or SteamOS alone. Because of this, Nebula has been re-contextualized as a Linux-first project rather than a Steam-native application.
|
||||
|
||||
---
|
||||
|
||||
## What This Means Now
|
||||
|
||||
* Nebula is **not abandoned**
|
||||
* It is **no longer a main or priority project**
|
||||
* Development happens **occasionally and without a fixed schedule**
|
||||
* The project is no longer tied to Steam or SteamOS
|
||||
* Linux desktop users, handhelds, and alternative input setups are the primary audience
|
||||
|
||||
Nebula exists as an ongoing experiment in controller- and keyboard-driven web navigation. It may evolve further, remain stable, or inspire forks and derivative projects.
|
||||
|
||||
---
|
||||
|
||||
## Distribution
|
||||
|
||||
Nebula may be distributed outside of Steam through platforms such as:
|
||||
|
||||
* GitHub (source and releases)
|
||||
* itch.io
|
||||
* Flatpak / Flathub
|
||||
|
||||
Availability and packaging may change over time and are not guaranteed.
|
||||
|
||||
Official releases for Nebula will be published on itch.io; Steam distribution
|
||||
is not available due to the Steam review outcome described above.
|
||||
|
||||
---
|
||||
|
||||
## Licensing
|
||||
|
||||
Nebula Browser is licensed under the MIT License.
|
||||
You are free to use, modify, and build upon the project.
|
||||
|
||||
|
||||
|
||||
## Features
|
||||
|
||||
* **Privacy Control:** Easily clear your browsing data (history, cookies, cache, local storage, and more).
|
||||
* **Tab Management:** Open new tabs, pop a tab out into a new window, and manage them efficiently.
|
||||
* **Bookmarks:** Save your favorite sites with automatic backup on save.
|
||||
* **History:** Keeps track of your browsing and search history with one-click clear.
|
||||
* **Downloads Manager:** Track downloads, pause/resume/cancel, and open or reveal completed files.
|
||||
* **Context Menu:** Native right‑click menu with Back/Forward/Reload, open/download links, image actions, and Inspect Element.
|
||||
* **Auth Compatibility:** Improved OAuth/SSO & WebAuthn support (popup windows enabled where needed).
|
||||
* **Performance Monitoring:** Built-in tools to monitor app performance and force GC when needed.
|
||||
* **GPU Acceleration Control:** Diagnostics and safe fallbacks to troubleshoot rendering issues.
|
||||
* **Themes & Customization:** Built-in themes and live editor to craft your own.
|
||||
* **Plugins:** Extend Nebula with custom or community plugins via a simple plugin API.
|
||||
* **Cross-Platform:** Runs on Windows, macOS, and Linux.
|
||||
|
||||
[**Learn more about Nebula's features.**](documentation/FEATURES.md)
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
* [Node.js](https://nodejs.org/) installed.
|
||||
|
||||
### Installation
|
||||
|
||||
1. Clone the repository:
|
||||
```sh
|
||||
git clone https://github.com/Bobbybear007/NebulaBrowser.git
|
||||
```
|
||||
2. Navigate to the project directory:
|
||||
```sh
|
||||
cd NebulaBrowser
|
||||
```
|
||||
3. Install dependencies:
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Running the Application
|
||||
|
||||
To start the browser, run the following command:
|
||||
|
||||
```sh
|
||||
npm start
|
||||
```
|
||||
|
||||
## Building the Application
|
||||
|
||||
To build the application for your platform, run:
|
||||
|
||||
```sh
|
||||
npm run dist
|
||||
```
|
||||
|
||||
This will create a distributable file in the `dist` directory.
|
||||
|
||||
Tip (Windows): If you encounter GPU issues, try starting with `start-gpu-safe.bat` to launch in a safer rendering mode.
|
||||
|
||||
## Project Structure
|
||||
|
||||
An overview of the project's structure. For a more detailed explanation, please see the [Project Structure documentation](documentation/PROJECT_STRUCTURE.md).
|
||||
|
||||
- `main.js`: The main entry point for the Electron application.
|
||||
- `renderer/`: Contains all the front-end files.
|
||||
- `preload.js`: Bridges the main and renderer processes.
|
||||
- `performance-monitor.js`: Module for monitoring performance.
|
||||
- `gpu-config.js` & `gpu-fallback.js`: Modules for managing GPU settings.
|
||||
- `assets/`: Contains static assets.
|
||||
- `documentation/`: Contains additional documentation.
|
||||
- `plugins/`: Sample plugins and scaffolding for developing your own.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
Nebula is built on several core concepts that are essential to understanding how it works. For a deeper dive, read the [Core Concepts documentation](documentation/CORE_CONCEPTS.md).
|
||||
|
||||
- **Main and Renderer Processes**
|
||||
- **Inter-Process Communication (IPC)**
|
||||
- **Performance and GPU Management**
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please read our [contributing guidelines](documentation/CONTRIBUTING.md) to get started.
|
||||
|
||||
## Technologies Used
|
||||
|
||||
* [Electron](https://www.electronjs.org/)
|
||||
* HTML, CSS, JavaScript
|
||||
|
||||
|
||||
|
||||
|
||||
## Documentation
|
||||
|
||||
* [MIT License](documentation/MIT.md)
|
||||
* [GPU Fix](documentation/GPU-FIX-README.md)
|
||||
* [Features](documentation/FEATURES.md)
|
||||
* [Customization](documentation/Customization.md)
|
||||
* [Project Structure](documentation/PROJECT_STRUCTURE.md)
|
||||
* [Core Concepts](documentation/CORE_CONCEPTS.md)
|
||||
* [Contributing Guide](documentation/CONTRIBUTING.md)
|
||||
* [OAuth Debug](documentation/oauth-debug.md)
|
||||
* [Plugins Guide](README-PLUGINS.md)
|
||||
@@ -0,0 +1,13 @@
|
||||
#include <windows.h>
|
||||
|
||||
#include "app/run.h"
|
||||
|
||||
int APIENTRY wWinMain(HINSTANCE instance,
|
||||
HINSTANCE previous_instance,
|
||||
LPWSTR command_line,
|
||||
int show_command) {
|
||||
UNREFERENCED_PARAMETER(previous_instance);
|
||||
UNREFERENCED_PARAMETER(command_line);
|
||||
|
||||
return nebula::app::RunNebula(instance, show_command);
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run Nebula from an extracted AppImage folder with safe LD/XDG paths and Linux flags.
|
||||
# This repo can end up with either:
|
||||
# - nebula-appdir/squashfs-root/nebula (common after copying squashfs-root into nebula-appdir)
|
||||
# - nebula-appdir/nebula (if you copied the extracted contents directly into nebula-appdir)
|
||||
#
|
||||
# PORTABLE MODE: User data (cookies, history, bookmarks) is stored in usr/data/ alongside the app.
|
||||
# This keeps all data self-contained and portable on Linux.
|
||||
set -euo pipefail
|
||||
|
||||
SELF_DIR="$(cd "$(dirname "$0")" >/dev/null 2>&1 && pwd)"
|
||||
|
||||
if [ -x "$SELF_DIR/nebula-appdir/nebula" ]; then
|
||||
APPROOT="$SELF_DIR/nebula-appdir"
|
||||
BIN="$APPROOT/nebula"
|
||||
elif [ -x "$SELF_DIR/squashfs-root/nebula" ]; then
|
||||
APPROOT="$SELF_DIR/squashfs-root"
|
||||
BIN="$APPROOT/nebula"
|
||||
elif [ -x "$SELF_DIR/nebula" ]; then
|
||||
APPROOT="$SELF_DIR"
|
||||
BIN="$APPROOT/nebula"
|
||||
else
|
||||
echo "Nebula binary not found. Expected either:"
|
||||
echo "- $SELF_DIR/nebula-appdir/nebula"
|
||||
echo "- $SELF_DIR/squashfs-root/nebula"
|
||||
echo "- $SELF_DIR/nebula"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- PORTABLE DATA CONFIGURATION ---
|
||||
# Store user data (cookies, history, bookmarks, etc.) in a local folder
|
||||
# Data is stored with secure permissions (700 for dirs, 600 for files)
|
||||
PORTABLE_DATA_DIR="$SELF_DIR/usr/data"
|
||||
export NEBULA_PORTABLE=1
|
||||
export NEBULA_PORTABLE_PATH="$PORTABLE_DATA_DIR"
|
||||
|
||||
# Create portable data directory with secure permissions if it doesn't exist
|
||||
if [ ! -d "$PORTABLE_DATA_DIR" ]; then
|
||||
mkdir -p "$PORTABLE_DATA_DIR"
|
||||
chmod 700 "$PORTABLE_DATA_DIR"
|
||||
fi
|
||||
|
||||
export LD_LIBRARY_PATH="$APPROOT/usr/lib:${LD_LIBRARY_PATH:-}"
|
||||
export XDG_DATA_DIRS="$APPROOT/usr/share:${XDG_DATA_DIRS:-/usr/share}"
|
||||
|
||||
# =============================================================================
|
||||
# STEAM INPUT CONFIGURATION - Always set these to prevent controller emulation
|
||||
# =============================================================================
|
||||
# These variables tell Steam's input layer that this app handles controller
|
||||
# input natively and should NOT have mouse/keyboard emulation applied.
|
||||
# Set unconditionally since Software apps may not receive Steam env vars.
|
||||
|
||||
# Disable Steam's virtual gamepad layer - CRITICAL for native controller support
|
||||
export SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD=0
|
||||
export STEAM_INPUT_ENABLE_VIRTUAL_GAMEPAD=0
|
||||
|
||||
# Allow raw gamepad access
|
||||
export SDL_GAMECONTROLLER_IGNORE_DEVICES=""
|
||||
|
||||
# Allow background gamepad events (useful when app doesn't have focus)
|
||||
export SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS=1
|
||||
|
||||
# Hint to SDL that we're using gamepads natively
|
||||
export SDL_GAMECONTROLLERCONFIG="${SDL_GAMECONTROLLERCONFIG:-}"
|
||||
|
||||
# Steam can inject its overlay via LD_PRELOAD (gameoverlayrenderer.so).
|
||||
# In some setups this breaks Chromium/Electron gamepad input. When we detect
|
||||
# a Steam-launched session, strip the overlay preload but keep other entries.
|
||||
# Also configure additional Steam-specific settings.
|
||||
if [ -n "${SteamAppId:-}" ] || [ -n "${STEAM_APP_ID:-}" ] || [ -n "${STEAM_COMPAT_CLIENT_INSTALL_PATH:-}" ] || [ -n "${STEAM_COMPAT_DATA_PATH:-}" ]; then
|
||||
# Enable Big Picture Mode for controller-friendly UI when launched from Steam
|
||||
export NEBULA_BIG_PICTURE="${NEBULA_BIG_PICTURE:-1}"
|
||||
|
||||
# Enable GPU acceleration on Linux
|
||||
export NEBULA_GPU_ALLOW_LINUX=1
|
||||
|
||||
if [ -n "${LD_PRELOAD:-}" ]; then
|
||||
CLEANED_LD_PRELOAD=""
|
||||
IFS=':' read -r -a _preload_parts <<< "$LD_PRELOAD"
|
||||
for _p in "${_preload_parts[@]}"; do
|
||||
[ -z "$_p" ] && continue
|
||||
case "$_p" in
|
||||
*gameoverlayrenderer.so*)
|
||||
;;
|
||||
*)
|
||||
if [ -z "$CLEANED_LD_PRELOAD" ]; then
|
||||
CLEANED_LD_PRELOAD="$_p"
|
||||
else
|
||||
CLEANED_LD_PRELOAD="$CLEANED_LD_PRELOAD:$_p"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
if [ -z "$CLEANED_LD_PRELOAD" ]; then
|
||||
unset LD_PRELOAD
|
||||
else
|
||||
export LD_PRELOAD="$CLEANED_LD_PRELOAD"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
GPU_ARGS=()
|
||||
|
||||
# Optional GPU tuning profiles strike a balance between enabling hardware decode
|
||||
# and keeping the launcher safe on systems with fragile Mesa/VA-API stacks.
|
||||
# Default to enabling GPU tweaks on Linux unless explicitly disabled.
|
||||
export NEBULA_GPU_PROFILE="${NEBULA_GPU_PROFILE:-}"
|
||||
GPU_GL_MODE="${NEBULA_GPU_GL:-}"
|
||||
export NEBULA_GPU_TWEAKS="${NEBULA_GPU_TWEAKS:-1}"
|
||||
|
||||
if [ "${NEBULA_GPU_TWEAKS}" = "1" ] && [ -z "$NEBULA_GPU_PROFILE" ]; then
|
||||
export NEBULA_GPU_PROFILE=vaapi
|
||||
fi
|
||||
|
||||
case "$NEBULA_GPU_PROFILE" in
|
||||
vaapi)
|
||||
GPU_ARGS+=(--ignore-gpu-blocklist)
|
||||
GPU_ARGS+=(--enable-features=VaapiVideoDecoder,VaapiVideoEncoder,CanvasOopRasterization,UseSkiaRenderer,VaapiVideoDecoderLinuxGL,VaapiVideoDecoderLinuxZeroCopyGL)
|
||||
GPU_ARGS+=(--enable-zero-copy)
|
||||
GPU_ARGS+=(--enable-gpu-rasterization)
|
||||
GPU_ARGS+=(--enable-accelerated-video-decode)
|
||||
GPU_ARGS+=(--enable-native-gpu-memory-buffers)
|
||||
if [ -z "${LIBVA_DRIVER_NAME:-}" ]; then
|
||||
export LIBVA_DRIVER_NAME=radeonsi
|
||||
fi
|
||||
;;
|
||||
angle)
|
||||
GPU_ARGS+=(--ignore-gpu-blocklist)
|
||||
GPU_ARGS+=(--enable-features=VaapiVideoDecoder,VaapiVideoEncoder,CanvasOopRasterization,UseSkiaRenderer,VaapiVideoDecoderLinuxGL)
|
||||
GPU_ARGS+=(--enable-zero-copy)
|
||||
GPU_ARGS+=(--enable-gpu-rasterization)
|
||||
GPU_ARGS+=(--enable-native-gpu-memory-buffers)
|
||||
GPU_ARGS+=(--enable-accelerated-video-decode)
|
||||
if [ -z "${LIBVA_DRIVER_NAME:-}" ]; then
|
||||
export LIBVA_DRIVER_NAME=radeonsi
|
||||
fi
|
||||
if [ -z "${AMD_VULKAN_ICD:-}" ]; then
|
||||
export AMD_VULKAN_ICD=RADV
|
||||
fi
|
||||
;;
|
||||
amd-handheld)
|
||||
GPU_ARGS+=(--ignore-gpu-blocklist)
|
||||
GPU_ARGS+=(--use-angle=vulkan)
|
||||
AMD_HANDHELD_FEATURES="VaapiVideoDecoder,VaapiVideoEncoder,CanvasOopRasterization,UseSkiaRenderer,VaapiVideoDecoderLinuxGL,VaapiVideoDecoderLinuxZeroCopyGL"
|
||||
if [ -n "${WAYLAND_DISPLAY:-}" ] || [ "${XDG_SESSION_TYPE:-}" = "wayland" ]; then
|
||||
AMD_HANDHELD_FEATURES="UseOzonePlatform,WaylandWindowDecorations,${AMD_HANDHELD_FEATURES}"
|
||||
GPU_ARGS+=(--ozone-platform-hint=auto)
|
||||
GPU_ARGS+=(--ozone-platform=wayland)
|
||||
fi
|
||||
GPU_ARGS+=(--enable-features="$AMD_HANDHELD_FEATURES")
|
||||
GPU_ARGS+=(--enable-zero-copy)
|
||||
GPU_ARGS+=(--enable-gpu-rasterization)
|
||||
if [ -z "${LIBVA_DRIVER_NAME:-}" ]; then
|
||||
export LIBVA_DRIVER_NAME=radeonsi
|
||||
fi
|
||||
if [ -z "${MESA_VK_WSI_PRESENT_MODE:-}" ]; then
|
||||
export MESA_VK_WSI_PRESENT_MODE=mailbox
|
||||
fi
|
||||
;;
|
||||
software)
|
||||
GPU_ARGS+=(--disable-gpu)
|
||||
;;
|
||||
"")
|
||||
;;
|
||||
*)
|
||||
echo "Warning: Unknown NEBULA_GPU_PROFILE '$NEBULA_GPU_PROFILE'" >&2
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -n "$GPU_GL_MODE" ]; then
|
||||
# Map common GL mode names to Electron-compatible values
|
||||
case "$GPU_GL_MODE" in
|
||||
egl|egl-angle) GPU_GL_MODE="angle" ;;
|
||||
desktop) GPU_GL_MODE="desktop" ;;
|
||||
esac
|
||||
GPU_ARGS+=(--use-gl="$GPU_GL_MODE")
|
||||
fi
|
||||
|
||||
if [ -n "${NEBULA_GPU_EXTRA_ARGS:-}" ]; then
|
||||
# shellcheck disable=SC2086 # word splitting is intentional for custom flag tokens
|
||||
read -r -a _nebula_gpu_extra <<< "${NEBULA_GPU_EXTRA_ARGS}"
|
||||
GPU_ARGS+=("${_nebula_gpu_extra[@]}")
|
||||
fi
|
||||
|
||||
exec "$BIN" --no-sandbox "${GPU_ARGS[@]}" "$@"
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/usr/bin/env xdg-open
|
||||
[Desktop Entry]
|
||||
Name=Nebula
|
||||
Comment=Nebula Browser (Portable Mode - data stored locally)
|
||||
Exec=env NEBULA_GPU_TWEAKS=1 /home/deck/Documents/Repos/NebulaBrowser/nebula-appdir/Nebula %U
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Icon=nebula
|
||||
Categories=Network;WebBrowser;
|
||||
StartupWMClass=Nebula
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Run Nebula with portable data storage
|
||||
# User data (cookies, history, bookmarks) is stored in usr/user-data/ alongside the app.
|
||||
set -e
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
export APPDIR="$HERE"
|
||||
export PATH="$HERE/usr/bin:$PATH"
|
||||
export LD_LIBRARY_PATH="$HERE/usr/lib:$HERE/usr/lib64:$LD_LIBRARY_PATH"
|
||||
|
||||
# --- PORTABLE DATA CONFIGURATION ---
|
||||
# Store user data in a local folder for portable operation
|
||||
PORTABLE_DATA_DIR="$HERE/usr/user-data"
|
||||
export NEBULA_PORTABLE=1
|
||||
export NEBULA_PORTABLE_PATH="$PORTABLE_DATA_DIR"
|
||||
|
||||
# Create portable data directory with secure permissions if it doesn't exist
|
||||
if [ ! -d "$PORTABLE_DATA_DIR" ]; then
|
||||
mkdir -p "$PORTABLE_DATA_DIR"
|
||||
chmod 700 "$PORTABLE_DATA_DIR"
|
||||
fi
|
||||
|
||||
exec "$HERE/nebula-appdir/AppRun"
|
||||
@@ -1 +0,0 @@
|
||||
4290110
|
||||
@@ -1,9 +0,0 @@
|
||||
[Desktop Entry]
|
||||
Name=Nebula
|
||||
Comment=Nebula Browser
|
||||
Exec=./Nebula %U
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Icon=nebula
|
||||
Categories=Network;WebBrowser;
|
||||
StartupWMClass=Nebula
|
||||
|
Before Width: | Height: | Size: 976 B After Width: | Height: | Size: 976 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 699 KiB |
|
Before Width: | Height: | Size: 352 KiB After Width: | Height: | Size: 352 KiB |
|
Before Width: | Height: | Size: 890 KiB After Width: | Height: | Size: 890 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -1,3 +0,0 @@
|
||||
[
|
||||
|
||||
]
|
||||
@@ -1 +0,0 @@
|
||||
[]
|
||||
@@ -1,168 +0,0 @@
|
||||
# Big Picture Mode - Steam Deck & Controller UI
|
||||
|
||||
Nebula Browser includes a **Big Picture Mode** - a controller-friendly, console-style interface designed for Steam Deck, handheld devices, and living room setups.
|
||||
|
||||
## 🚀 Single Window Architecture
|
||||
|
||||
Big Picture Mode now opens **in the main window** instead of a separate window. This design:
|
||||
- **Keeps resources low** - No extra Electron window process
|
||||
- **Prevents SteamOS conflicts** - When auto-launching in Gaming Mode, Steam won't create a desktop mode alongside, preventing Steam from overriding controls to emulate keyboard/mouse
|
||||
- **Seamless switching** - Navigate between Desktop and Big Picture modes smoothly
|
||||
|
||||
## ⚠️ Steam Deck: Disabling Mouse Emulation
|
||||
|
||||
If Steam is emulating mouse/keyboard input with the joysticks (overriding native controller support), you need to configure Steam Input:
|
||||
|
||||
### Quick Fix - Steam Launch Options
|
||||
|
||||
Add this to your Steam launch options (Right-click → Properties → Launch Options):
|
||||
|
||||
```bash
|
||||
SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD=0 STEAM_INPUT_ENABLE_VIRTUAL_GAMEPAD=0 %command% --big-picture
|
||||
```
|
||||
|
||||
### Recommended Fix - Disable Steam Input Per-Game
|
||||
|
||||
1. In Steam, right-click **Nebula** → **Properties** → **Controller**
|
||||
2. Set **"Override for Nebula"** to **"Disable Steam Input"**
|
||||
3. This completely disables Steam's input emulation for this app
|
||||
|
||||
### Using the Steam Deck Launcher Script
|
||||
|
||||
For the easiest setup, use the included launcher script:
|
||||
|
||||
1. Set Steam launch options to: `./start-steamdeck.sh`
|
||||
|
||||
The script automatically sets all necessary environment variables.
|
||||
|
||||
See [README-STEAM.md](../README-STEAM.md) for detailed troubleshooting.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### 🎮 Controller Support
|
||||
- **Full gamepad navigation** - Use D-pad or left stick to navigate
|
||||
- **Button mapping**:
|
||||
- **A / Cross** - Select/Activate
|
||||
- **B / Circle** - Go Back
|
||||
- **Y / Triangle** - Quick Search
|
||||
- **Start** - Toggle Settings
|
||||
- **Audio feedback** for navigation
|
||||
|
||||
### 📱 Optimized for Steam Deck
|
||||
- **1280x800 native resolution** support
|
||||
- Automatic detection of Steam Deck screens
|
||||
- Large touch-friendly UI elements
|
||||
- Fullscreen immersive experience
|
||||
|
||||
### 🎨 Modern Console-Style UI
|
||||
- Inspired by Steam OS Big Picture and Xbox Dashboard
|
||||
- Smooth animations and transitions
|
||||
- Glowing focus indicators
|
||||
- Dark theme optimized for OLED displays
|
||||
|
||||
### ⌨️ On-Screen Keyboard
|
||||
- Built-in virtual keyboard for controller input
|
||||
- URL and search input support
|
||||
- Special keys for common domains (.com, .org, etc.)
|
||||
|
||||
## How to Access
|
||||
|
||||
### From Desktop Mode
|
||||
1. **Menu Button (☰)** → Click **"🎮 Big Picture Mode"**
|
||||
2. **Settings** → **General** → Click **"Launch Big Picture Mode"**
|
||||
|
||||
### Keyboard Shortcut
|
||||
- Press `F11` while in Big Picture Mode to toggle fullscreen
|
||||
|
||||
### Automatic Detection
|
||||
If Nebula detects a Steam Deck-sized display (1280x800), it will suggest Big Picture Mode in settings.
|
||||
|
||||
### Auto-start in SteamOS Gaming Mode
|
||||
When Nebula is launched from SteamOS **Gaming Mode** (gamescope / Steam gamepad UI), it will automatically start in **Big Picture Mode**.
|
||||
|
||||
You can override this behavior:
|
||||
- Force Big Picture at launch: launch options `--big-picture` (or `--bigpicture`)
|
||||
- Disable Big Picture auto-start: launch options `--no-big-picture` (or `--no-bigpicture`)
|
||||
- Environment overrides: `NEBULA_BIG_PICTURE=1` / `NEBULA_NO_BIG_PICTURE=1`
|
||||
|
||||
## Navigation Sections
|
||||
|
||||
| Section | Description |
|
||||
|---------|-------------|
|
||||
| **Home** | Quick access sites, search, and recent browsing |
|
||||
| **Bookmarks** | Your saved websites in a tile grid |
|
||||
| **History** | Recently visited sites |
|
||||
| **Downloads** | Downloaded files |
|
||||
| **NeBot AI** | Launch the AI assistant |
|
||||
| **Settings** | Theme, privacy, and display options |
|
||||
|
||||
## Controller Button Reference
|
||||
|
||||
| Button | Action |
|
||||
|--------|--------|
|
||||
| D-Pad / Left Stick | Navigate between elements |
|
||||
| A / Cross | Select focused element |
|
||||
| B / Circle | Go back / Close menu |
|
||||
| Y / Triangle | Open search (on-screen keyboard) |
|
||||
| Start | Open/Close settings |
|
||||
| LB/RB | Scroll horizontally |
|
||||
|
||||
## Exiting Big Picture Mode
|
||||
|
||||
- Press the **Exit** button in the top-right corner
|
||||
- Go to **Settings** → **Desktop Mode**
|
||||
- Press `Escape` key multiple times
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Files
|
||||
- `renderer/bigpicture.html` - Main HTML structure
|
||||
- `renderer/bigpicture.css` - Console-optimized styles
|
||||
- `renderer/bigpicture.js` - Controller handling and navigation
|
||||
|
||||
### Screen Detection
|
||||
Big Picture Mode is suggested for displays matching:
|
||||
- Steam Deck resolution: 1280×800
|
||||
- Screens smaller than 1366px width
|
||||
- 16:10 or 16:9 aspect ratios
|
||||
|
||||
### API
|
||||
```javascript
|
||||
// Check if Big Picture Mode is recommended
|
||||
const suggested = await window.bigPictureAPI.isSuggested();
|
||||
|
||||
// Get screen information
|
||||
const info = await window.bigPictureAPI.getScreenInfo();
|
||||
|
||||
// Check if currently in Big Picture Mode
|
||||
const isActive = await window.bigPictureAPI.isActive();
|
||||
|
||||
// Launch Big Picture Mode (navigates main window)
|
||||
await window.bigPictureAPI.launch();
|
||||
|
||||
// Exit Big Picture Mode (navigates back to desktop UI)
|
||||
await window.bigPictureAPI.exit();
|
||||
```
|
||||
|
||||
## Customization
|
||||
|
||||
The Big Picture Mode respects your theme settings. Colors are applied from your selected theme:
|
||||
- Background colors
|
||||
- Accent and primary colors
|
||||
- Text colors
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- Some complex web forms may be difficult to navigate with controller only
|
||||
- Video players use native controls
|
||||
- Right-click context menus require mouse/touch
|
||||
|
||||
## Future Improvements
|
||||
|
||||
- [ ] Rumble/haptic feedback for compatible controllers
|
||||
- [ ] Voice search integration with NeBot
|
||||
- [ ] Picture-in-picture mode for videos
|
||||
- [ ] Game overlay mode
|
||||
- [ ] Custom controller mappings
|
||||
@@ -1,119 +0,0 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that are compassionate, direct, and respectful.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards and
|
||||
will take appropriate and fair corrective action in response to any behavior that
|
||||
they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at [INSERT CONTACT METHOD].
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interaction in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.1, available at
|
||||
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
@@ -1,65 +0,0 @@
|
||||
# Contributing to Nebula
|
||||
|
||||
First off, thank you for considering contributing to Nebula! It's people like you that make open source such a great community.
|
||||
|
||||
## How Can I Contribute?
|
||||
|
||||
### Reporting Bugs
|
||||
|
||||
- Ensure the bug was not already reported by searching on GitHub under [Issues](https://github.com/Bobbybear007/NebulaBrowser/issues).
|
||||
- If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/Bobbybear007/NebulaBrowser/issues/new). Be sure to include a **title and clear description**, as much relevant information as possible, and a **code sample** or an **executable test case** demonstrating the expected behavior that is not occurring.
|
||||
|
||||
### Suggesting Enhancements
|
||||
|
||||
- Open a new issue to discuss your enhancement. Please provide a clear description of the enhancement and its potential benefits.
|
||||
|
||||
### Pull Requests
|
||||
|
||||
1. **Fork the repository** to your own GitHub account.
|
||||
2. **Clone the project** to your machine.
|
||||
3. **Create a new branch** for your changes:
|
||||
```sh
|
||||
git checkout -b feature/your-feature-name
|
||||
```
|
||||
4. **Make your changes** and commit them with a clear, descriptive commit message:
|
||||
```sh
|
||||
git commit -m "Add some feature"
|
||||
```
|
||||
5. **Push your branch** to your fork:
|
||||
```sh
|
||||
git push origin feature/your-feature-name
|
||||
```
|
||||
6. **Open a pull request** to the `main` branch of the original repository. Provide a clear title and description for your pull request, explaining the changes you've made.
|
||||
|
||||
## Styleguides
|
||||
|
||||
### Git Commit Messages
|
||||
|
||||
- Use the present tense ("Add feature" not "Added feature").
|
||||
- Use the imperative mood ("Move cursor to..." not "Moves cursor to...").
|
||||
- Limit the first line to 72 characters or less.
|
||||
- Reference issues and pull requests liberally after the first line.
|
||||
|
||||
### Code of Conduct
|
||||
|
||||
We have a [Code of Conduct](CODE_OF_CONDUCT.md) that all contributors are expected to follow. Please make sure you are familiar with its contents.
|
||||
|
||||
### JavaScript Styleguide
|
||||
|
||||
- All JavaScript must adhere to [StandardJS](https://standardjs.com/). This helps us maintain a consistent coding style.
|
||||
- Use soft-tabs with a two-space indent.
|
||||
- Prefer single quotes `'` over double quotes `"`.
|
||||
- No semicolons.
|
||||
- For more details, please refer to the [StandardJS rules](https://standardjs.com/rules.html).
|
||||
|
||||
### CSS Styleguide
|
||||
|
||||
- Follow a BEM-like naming convention for classes (`block__element--modifier`).
|
||||
- Use soft-tabs with a two-space indent.
|
||||
- Write selectors and their properties on separate lines.
|
||||
- Organize properties logically (e.g., positioning, box model, typography, visual).
|
||||
- Use `rem` for font sizes and `px` for borders.
|
||||
- Use `===` and `!==` instead of `==` and `!=` for comparisons.
|
||||
- Always declare variables with `const` or `let` instead of `var`.
|
||||
- Use arrow functions instead of `function` where appropriate.
|
||||
- Prefer template literals over string concatenation.
|
||||
@@ -1,32 +0,0 @@
|
||||
# Core Concepts
|
||||
|
||||
This document explains the core architectural concepts of the Nebula browser.
|
||||
|
||||
### Main and Renderer Processes
|
||||
|
||||
Electron applications have two types of processes: the **main process** and one or more **renderer processes**.
|
||||
|
||||
- **Main Process**: The main process, which runs the `main.js` script, is the entry point of the application. It runs in a Node.js environment, meaning it has access to all Node.js APIs like `fs` for file system access and `ipcMain` for communication. It is responsible for creating and managing `BrowserWindow` instances, which are the application's windows.
|
||||
|
||||
- **Renderer Process**: Each `BrowserWindow` runs its own renderer process. The renderer process is responsible for rendering web content—in Nebula's case, the browser's user interface (UI) built with HTML, CSS, and JavaScript. The renderer process does not have direct access to Node.js APIs for security reasons.
|
||||
|
||||
### Inter-Process Communication (IPC)
|
||||
|
||||
Since the main and renderer processes are separate, they need a way to communicate. This is done through Inter-Process Communication (IPC).
|
||||
|
||||
- **`ipcMain` and `ipcRenderer`**: Electron provides the `ipcMain` and `ipcRenderer` modules for this purpose. The main process listens for messages using `ipcMain.handle`, and the renderer sends messages using `ipcRenderer.invoke`.
|
||||
- Nebula uses IPC for features like downloads control, bookmarks/history management, performance reports, GPU diagnostics, and window controls.
|
||||
|
||||
- **Context Bridge and Preload Script**: To securely expose APIs from the main process to the renderer process, Electron uses a **preload script** and the **context bridge**. The `preload.js` script runs in a special environment that has access to both the `window` object of the renderer process and Node.js APIs. The `contextBridge` is used to expose specific functions from the preload script to the renderer process, ensuring that the renderer process cannot access powerful Node.js APIs directly.
|
||||
|
||||
### Performance and GPU Management
|
||||
|
||||
- **Performance Monitoring**: The `performance-monitor.js` module helps track the application's performance by monitoring metrics like memory usage and page load times. This is essential for identifying and addressing performance bottlenecks.
|
||||
|
||||
- **GPU Configuration**: The `gpu-config.js` and `gpu-fallback.js` modules manage GPU acceleration. Electron uses the system's GPU to render content, which can significantly improve performance. However, GPU drivers can sometimes be a source of instability. These modules allow Nebula to check the GPU status and apply fallbacks (like disabling hardware acceleration) if issues are detected, ensuring a more stable experience.
|
||||
|
||||
### Authentication & User Agent Strategy
|
||||
|
||||
- **Auth Flow Compatibility**: Nebula allows popup windows for http/https to preserve OAuth/SSO flows without stripping POST bodies.
|
||||
- **WebAuthn**: Platform authenticator features are enabled where supported, with diagnostics logged to help troubleshoot availability.
|
||||
- **User Agent**: The default Electron token is removed from the UA string to improve site compatibility while appending a `Nebula/<version>` marker.
|
||||
@@ -1,68 +0,0 @@
|
||||
# Nebula Browser Themes
|
||||
|
||||
This directory contains theme files for the Nebula Browser customization system.
|
||||
|
||||
## Theme Structure
|
||||
|
||||
Each theme is a JSON file with the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Theme Name",
|
||||
"colors": {
|
||||
"bg": "#121418",
|
||||
"darkBlue": "#0B1C2B",
|
||||
"darkPurple": "#1B1035",
|
||||
"primary": "#7B2EFF",
|
||||
"accent": "#00C6FF",
|
||||
"text": "#E0E0E0"
|
||||
},
|
||||
"layout": "centered",
|
||||
"showLogo": true,
|
||||
"customTitle": "Nebula Browser",
|
||||
"gradient": "linear-gradient(145deg, #121418 0%, #1B1035 100%)",
|
||||
"version": "1.0",
|
||||
"description": "Theme description"
|
||||
}
|
||||
```
|
||||
|
||||
## Color Properties
|
||||
|
||||
- `bg`: Main background color
|
||||
- `darkBlue`: Secondary dark blue accent
|
||||
- `darkPurple`: Secondary dark purple accent
|
||||
- `primary`: Primary accent color (used for buttons, logos)
|
||||
- `accent`: Secondary accent color (used for highlights)
|
||||
- `text`: Main text color
|
||||
|
||||
## Layout Options
|
||||
|
||||
- `centered`: Default centered layout
|
||||
- `sidebar`: Sidebar navigation layout
|
||||
- `compact`: Compact view layout
|
||||
|
||||
## Directories
|
||||
|
||||
- `/downloaded/`: Themes downloaded from the community
|
||||
- `/user/`: User-created custom themes
|
||||
|
||||
## Usage
|
||||
|
||||
1. **Import Theme**: Go to Settings > Customization > Import Theme
|
||||
2. **Export Theme**: Create your custom theme and export it
|
||||
3. **Share Themes**: Share your exported .json files with other users
|
||||
|
||||
## Creating Custom Themes
|
||||
|
||||
1. Go to Settings > Browser Customization
|
||||
2. Adjust colors and settings using the controls
|
||||
3. Use the live preview to see changes
|
||||
4. Save as custom theme or export to share
|
||||
|
||||
## Community Themes
|
||||
|
||||
Place downloaded community themes in the `/downloaded/` folder. The browser will automatically detect and make them available in the theme selector.
|
||||
|
||||
## Non-Destructive Design
|
||||
|
||||
All theme changes are stored separately and can be reset to default at any time. Your customizations never modify the original browser files.
|
||||
@@ -1,96 +0,0 @@
|
||||
# Features
|
||||
|
||||
This document provides a detailed overview of the features available in Nebula.
|
||||
|
||||
### Privacy Control
|
||||
|
||||
Nebula is designed with your privacy in mind. You have granular control over your browsing data.
|
||||
|
||||
- **Clear Browsing Data:** You can easily clear your browsing history, cookies, cache, and local storage. This can be done from the settings page.
|
||||
- **No Tracking by Default:** Nebula does not collect any personal data for tracking or advertising purposes.
|
||||
|
||||
### Tab Management
|
||||
|
||||
Efficiently manage your browsing session with Nebula's tab management features.
|
||||
|
||||
- **New Tabs:** Open new tabs to browse multiple websites at once.
|
||||
- **Tab Controls:** Each tab has standard controls for closing.
|
||||
- **Open in New Window:** You can pop a tab out into its own separate window.
|
||||
|
||||
### Bookmarks
|
||||
|
||||
Save and access your favorite websites with ease.
|
||||
|
||||
- **Add Bookmarks:** Save the current page to your bookmarks.
|
||||
- **View Bookmarks:** Access your saved bookmarks from the bookmarks bar or a dedicated page.
|
||||
|
||||
### History
|
||||
|
||||
Nebula keeps a record of your browsing and search history to help you find your way back to previously visited sites.
|
||||
|
||||
- **Site History:** A list of all the websites you have visited.
|
||||
- **Search History:** A list of all the searches you have made.
|
||||
- **Clear History:** You can clear your history at any time from the settings page.
|
||||
|
||||
### Downloads Manager
|
||||
|
||||
Reliably download files with progress and controls.
|
||||
|
||||
- **Progress & State:** Each download shows received/total bytes and status.
|
||||
- **Pause/Resume/Cancel:** Control active downloads where the server supports resuming.
|
||||
- **Open or Reveal:** Open downloaded files directly or show them in your file manager.
|
||||
- **Safe Filenames:** Files are saved to your OS Downloads folder with automatic de-duplication.
|
||||
|
||||
### Performance Monitoring
|
||||
|
||||
Nebula includes built-in tools to help you monitor the browser's performance.
|
||||
|
||||
- **Performance Report:** View a detailed report of performance metrics, including page load times and memory usage.
|
||||
- **Force Garbage Collection:** Manually trigger garbage collection to free up memory.
|
||||
|
||||
### GPU Acceleration Control
|
||||
|
||||
For advanced users, Nebula provides tools to manage GPU acceleration.
|
||||
|
||||
- **GPU Diagnostics:** View detailed information about your system's GPU and its status.
|
||||
- **GPU Fallback:** If you experience rendering issues, you can apply a GPU fallback to use a more stable rendering path. This can help resolve visual glitches or crashes.
|
||||
|
||||
### Native Context Menu
|
||||
|
||||
Nebula provides a native right-click menu across pages and webviews.
|
||||
|
||||
- **Navigation:** Back, Forward, Reload.
|
||||
- **Links:** Open in new tab, Download link, Open externally, Copy address.
|
||||
- **Images:** Open in new tab, Save image as, Copy image address.
|
||||
- **Editing:** Undo/Redo, Cut/Copy/Paste, Select All when applicable.
|
||||
- **Developer:** Inspect Element (opens DevTools docked by default).
|
||||
|
||||
### Authentication & Web Compatibility
|
||||
|
||||
- **OAuth/SSO Friendly:** Popup windows are allowed for http/https to support common login flows.
|
||||
- **WebAuthn Diagnostics:** Platform authenticator features are enabled where supported and logged for troubleshooting.
|
||||
- **Sturdy Navigation:** Login POST navigations are not intercepted by the main process.
|
||||
|
||||
### Custom Themes & Customization
|
||||
|
||||
Nebula offers extensive customization options to personalize your browsing experience.
|
||||
|
||||
- **Theme System:** Choose from built-in themes (default, forest, ocean, sunset, cyberpunk, midnight-rose, arctic-ice, cherry-blossom, cosmic-purple, emerald-dream, mocha-coffee, lavender-fields) or create your own custom themes.
|
||||
- **Live Theme Editor:** Modify colors, gradients, and layout options with real-time preview in the settings.
|
||||
- **Import/Export Themes:** Share custom themes with the community or use themes created by other users.
|
||||
- **Non-Destructive Design:** All customizations are stored separately and can be reset to default at any time.
|
||||
- **Layout Options:** Switch between centered, sidebar, and compact view layouts.
|
||||
- **Custom Branding:** Personalize the browser title and logo visibility.
|
||||
|
||||
For detailed information about creating and managing themes, see the [Customization Guide](Customization.md).
|
||||
|
||||
### Cross-Platform
|
||||
|
||||
Nebula is built with Electron, allowing it to run on multiple operating systems.
|
||||
|
||||
- **Windows, macOS, and Linux:** Enjoy a consistent browsing experience across different platforms.
|
||||
|
||||
### Miscellaneous
|
||||
|
||||
- **User Agent Strategy:** Electron token is removed from the default user agent to improve site compatibility while appending a `Nebula/x.y.z` marker. You can opt-in to include the Electron token by setting `NEBULA_DEBUG_ELECTRON_UA=1`.
|
||||
- **Open Local Files:** Use the file picker to open `file://` URLs directly.
|
||||
@@ -1,158 +0,0 @@
|
||||
# Nebula Browser - GPU Error 18 Fix & Performance Optimizations
|
||||
|
||||
## Problem Solved ✅
|
||||
**Error 18** - GPU process launch failure has been resolved. The browser now starts successfully and uses the best available rendering method.
|
||||
|
||||
## What Was Fixed
|
||||
|
||||
### 1. GPU Configuration System
|
||||
- **New GPU Config Manager**: Created `gpu-config.js` that intelligently handles GPU setup
|
||||
- **Automatic Fallback**: If GPU fails, automatically switches to software rendering
|
||||
- **Progressive Enhancement**: Tries GPU acceleration first, falls back gracefully
|
||||
- **No More Crashes**: Error 18 eliminated through proper GPU process handling
|
||||
|
||||
### 2. Command Line Optimizations
|
||||
**Essential Fixes:**
|
||||
```javascript
|
||||
app.commandLine.appendSwitch('no-sandbox');
|
||||
app.commandLine.appendSwitch('disable-dev-shm-usage');
|
||||
app.commandLine.appendSwitch('disable-gpu-sandbox');
|
||||
```
|
||||
|
||||
**Performance Improvements:**
|
||||
```javascript
|
||||
app.commandLine.appendSwitch('disable-background-timer-throttling');
|
||||
app.commandLine.appendSwitch('disable-renderer-backgrounding');
|
||||
app.commandLine.appendSwitch('max_old_space_size', '4096');
|
||||
```
|
||||
|
||||
### 3. Smart GPU Detection
|
||||
The browser now:
|
||||
- ✅ Detects GPU capabilities at startup
|
||||
- ✅ Provides clear status information
|
||||
- ✅ Offers recommendations for improvements
|
||||
- ✅ Gracefully handles GPU failures
|
||||
|
||||
## Performance Improvements Applied
|
||||
|
||||
### 1. Memory Management
|
||||
- **Debounced History Recording**: Reduces file I/O operations
|
||||
- **Async File Operations**: Prevents UI blocking
|
||||
- **Garbage Collection**: Manual GC triggering available
|
||||
- **Memory Monitoring**: Built-in performance tracking
|
||||
|
||||
### 2. Rendering Optimizations
|
||||
- **Hardware Acceleration**: When available, uses GPU for better performance
|
||||
- **Software Fallback**: Stable rendering when GPU isn't available
|
||||
- **CSS Optimizations**: Hardware-accelerated animations and scrolling
|
||||
- **Efficient Paint Management**: Reduced repaints and reflows
|
||||
|
||||
### 3. Caching & Network
|
||||
- **Request Caching**: HTTP cache headers for faster loading
|
||||
- **Resource Preloading**: Critical resources loaded early
|
||||
- **QUIC Protocol**: Faster network connections
|
||||
- **localStorage Optimization**: Efficient bookmark and history management
|
||||
|
||||
## Current Status
|
||||
|
||||
### GPU Status:
|
||||
- **Hardware Acceleration**: ❌ Not available on this system
|
||||
- **Software Rendering**: ✅ Working perfectly
|
||||
- **Stability**: ✅ No crashes, no Error 18
|
||||
- **Performance**: ✅ Optimized for software rendering
|
||||
|
||||
### Browser Performance:
|
||||
- **Startup Time**: ⚡ Significantly improved
|
||||
- **Memory Usage**: 📉 Reduced and monitored
|
||||
- **Responsiveness**: ✅ Smooth UI interactions
|
||||
- **Stability**: ✅ Robust error handling
|
||||
|
||||
## Diagnostic Tools Added
|
||||
|
||||
### 1. GPU Diagnostics Page
|
||||
Location: `renderer/gpu-diagnostics.html`
|
||||
- Real-time GPU status monitoring
|
||||
- WebGL and Canvas 2D testing
|
||||
- Performance metrics
|
||||
- Manual fallback controls
|
||||
|
||||
### 2. Performance Monitor
|
||||
- Memory usage tracking
|
||||
- CPU monitoring
|
||||
- Load time analysis
|
||||
- Automatic reporting every 5 minutes
|
||||
|
||||
### 3. Startup Script
|
||||
Location: `start-gpu-safe.bat`
|
||||
- Multiple GPU configuration options
|
||||
- Debug mode with verbose logging
|
||||
- Administrator privilege checking
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
### Normal Startup:
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
### Diagnostic Startup:
|
||||
```bash
|
||||
start-gpu-safe.bat
|
||||
```
|
||||
|
||||
### Check GPU Status:
|
||||
1. Open browser
|
||||
2. Navigate to GPU diagnostics page
|
||||
3. View real-time status and recommendations
|
||||
|
||||
## Why GPU Might Be Disabled
|
||||
|
||||
Common reasons for GPU acceleration being unavailable:
|
||||
1. **Outdated Drivers**: Graphics drivers need updating
|
||||
2. **Hardware Limitations**: Older or integrated graphics
|
||||
3. **Windows Settings**: Hardware acceleration disabled in system
|
||||
4. **Virtual Environment**: Running in VM or remote desktop
|
||||
5. **Security Software**: Antivirus blocking GPU access
|
||||
|
||||
## Recommendations
|
||||
|
||||
### For Better Performance:
|
||||
1. **Update Graphics Drivers**: Check manufacturer website
|
||||
2. **Windows Update**: Ensure system is up to date
|
||||
3. **Hardware Acceleration**: Enable in Windows display settings
|
||||
4. **Run as Administrator**: May help with GPU access
|
||||
5. **Check Antivirus**: Temporarily disable to test
|
||||
|
||||
### Current Configuration Works:
|
||||
Even without GPU acceleration, the browser is now:
|
||||
- ⚡ **Fast**: Software rendering optimized
|
||||
- 🛡️ **Stable**: No crashes or errors
|
||||
- 🔧 **Configurable**: Easy to adjust settings
|
||||
- 📊 **Monitored**: Performance tracking included
|
||||
|
||||
## Files Modified/Added
|
||||
|
||||
### Core Files:
|
||||
- `main.js` - Enhanced GPU handling, performance optimizations
|
||||
- `preload.js` - Improved API exposure with caching
|
||||
- `performance-monitor.js` - System performance tracking
|
||||
|
||||
### GPU Management:
|
||||
- `gpu-config.js` - Intelligent GPU configuration
|
||||
- `gpu-fallback.js` - Crash handling and fallbacks
|
||||
- `start-gpu-safe.bat` - Diagnostic startup script
|
||||
|
||||
### UI/CSS:
|
||||
- `performance.css` - Hardware acceleration optimizations
|
||||
- `gpu-diagnostics.html` - GPU status and testing page
|
||||
|
||||
## Result: ✅ Problem Solved
|
||||
|
||||
Your Nebula browser now:
|
||||
1. **Starts without Error 18** ✅
|
||||
2. **Runs smoothly on your system** ✅
|
||||
3. **Uses optimal rendering method** ✅
|
||||
4. **Provides performance monitoring** ✅
|
||||
5. **Offers diagnostic tools** ✅
|
||||
|
||||
The browser is optimized to work great with or without GPU acceleration!
|
||||
@@ -1,31 +0,0 @@
|
||||
# MIT License
|
||||
|
||||
The NebulaBrowser project is licensed under the MIT License. This license is a permissive open-source license that allows users to freely use, modify, distribute, and sublicense the software, provided that the original copyright notice and permission notice are included in all copies or substantial portions of the software.
|
||||
|
||||
## Key Points of the MIT License
|
||||
|
||||
1. **Permission to Use**: You are free to use this software for personal, educational, or commercial purposes without any restrictions.
|
||||
2. **Modification and Distribution**: You can modify the source code and distribute your modified versions, as long as the original copyright notice is retained.
|
||||
3. **No Warranty**: The software is provided "as is," without warranty of any kind. The authors are not liable for any damages or issues arising from the use of the software.
|
||||
|
||||
## How It Applies to NebulaBrowser
|
||||
|
||||
- **Open Contribution**: Developers are encouraged to contribute to the NebulaBrowser project. Contributions will also fall under the MIT License, ensuring that the project remains open and accessible to everyone.
|
||||
- **Commercial Use**: Businesses can integrate NebulaBrowser into their products or services without needing to pay royalties or seek additional permissions.
|
||||
- **Attribution**: Any distribution of NebulaBrowser or its derivatives must include the original copyright notice to acknowledge the work of the contributors.
|
||||
|
||||
## Copyright Notice
|
||||
|
||||
```
|
||||
Copyright (c) 2025 Zambazos Media Group
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
```
|
||||
|
||||
By using or contributing to NebulaBrowser, you agree to the terms of the MIT License.
|
||||
|
||||
More on the MIT License [Here](https://tlo.mit.edu/understand-ip/exploring-mit-open-source-license-comprehensive-guide)
|
||||
@@ -1,40 +0,0 @@
|
||||
# Project Structure
|
||||
|
||||
This document provides an in-depth look at the project's file and directory structure.
|
||||
|
||||
- **`main.js`**: This is the heart of the Electron application. It's the main process script that controls the application's lifecycle, creates browser windows, and handles all interactions with the operating system.
|
||||
- Manages native context menu, downloads, and OAuth/WebAuthn-friendly window behavior.
|
||||
|
||||
- **`renderer/`**: This directory contains all the client-side code and assets for the browser's user interface (the renderer process).
|
||||
- **`index.html`**: The main HTML file that serves as the container for the browser's UI, including the tab bar, address bar, and the webview for displaying web content.
|
||||
- **`style.css`**: The primary stylesheet for the entire browser interface.
|
||||
- **`script.js`**: The main JavaScript file for the renderer process. It handles all the user interactions within the browser window, such as creating new tabs, handling navigation, and communicating with the main process.
|
||||
- **`home.html`**, **`home.css`**, **`home.js`**: These files define the content and behavior of the default home page (new tab page).
|
||||
- **`settings.html`**, **`settings.css`**, **`settings.js`**: These files create the settings page, allowing users to configure the browser and manage their data.
|
||||
- **`404.html`**, **`404.css`**: Files for the "page not found" error page.
|
||||
- **`gpu-diagnostics.html`**: The page for displaying GPU information.
|
||||
- **`performance.css`**: Styles for the performance monitoring page.
|
||||
- **`icons.js`**, **`icons.json`**: Files related to managing icons within the UI.
|
||||
- Other pages: downloads UI and settings integrate with main-process IPC.
|
||||
|
||||
- **`preload.js`**: This script is a crucial part of Electron's security model. It runs in a privileged context before the renderer process's web page is loaded. It's used to selectively expose APIs from the main process to the renderer process via the `contextBridge`.
|
||||
|
||||
- **`performance-monitor.js`**: A Node.js module that runs in the main process to track application performance metrics like memory usage and page load times.
|
||||
|
||||
- **`gpu-config.js`** & **`gpu-fallback.js`**: These modules are responsible for managing GPU-related settings. `gpu-config.js` checks the system's GPU capabilities, and `gpu-fallback.js` provides mechanisms to disable or reduce GPU acceleration if problems are detected.
|
||||
- `start-gpu-safe.bat` starts the app with safer GPU settings on Windows.
|
||||
|
||||
- **`assets/`**: This directory holds all static assets.
|
||||
- **`images/`**: Contains logos, icons, and other images used in the application.
|
||||
- **`fonts/`**: Contains font files.
|
||||
|
||||
- **`documentation/`**: Contains all supplementary documentation for the project.
|
||||
|
||||
- **`*.json`**: Configuration and data files.
|
||||
- **`package.json`**: Defines the project's metadata, dependencies, and scripts.
|
||||
- **`bookmarks.json`**: Stores the user's bookmarks.
|
||||
- **`site-history.json`**: Stores the user's browsing history.
|
||||
- **`search-history.json`**: Stores the user's search history.
|
||||
- **`bookmarks.backup.json`**: Auto-created backup of bookmarks on save.
|
||||
|
||||
- **`start-gpu-safe.bat`**: A batch script for Windows users to start the application in a GPU-safe mode.
|
||||
@@ -1,113 +0,0 @@
|
||||
# Uploading releases to itch.io using Butler
|
||||
|
||||
This document explains how to prepare and upload Nebula releases to itch.io using Butler, covering macOS, Windows, and Linux.
|
||||
|
||||
## Overview
|
||||
|
||||
Butler (itch.io) is the recommended channel for distributing Nebula releases. The process is:
|
||||
|
||||
1. Build your platform-specific artifact.
|
||||
2. Install and authenticate `butler`.
|
||||
3. Push the build to your `username/game:channel` with `butler push`.
|
||||
|
||||
## Install Butler
|
||||
|
||||
- macOS / Linux / Windows: Download the appropriate Butler binary from the official itch.io Butler releases (follow the official docs). Unpack, make executable, and place it on your `PATH`.
|
||||
|
||||
Example (Linux/macOS):
|
||||
|
||||
```bash
|
||||
# after downloading 'butler' binary
|
||||
chmod +x butler
|
||||
sudo mv butler /usr/local/bin/
|
||||
```
|
||||
|
||||
On Windows, place `butler.exe` in a folder on `PATH` or use it directly from the build folder.
|
||||
|
||||
## Authenticate
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
butler login
|
||||
```
|
||||
|
||||
This opens a browser-based authentication flow. Verify with:
|
||||
|
||||
```bash
|
||||
butler whoami
|
||||
```
|
||||
|
||||
If automation is required, consult the official Butler docs for API-key/token login options.
|
||||
|
||||
## Prepare platform artifacts
|
||||
|
||||
macOS
|
||||
- Create a zip of your `.app` bundle (keep the `.app` as a top-level item inside the zip):
|
||||
|
||||
```bash
|
||||
ditto -c -k --sequesterRsrc --keepParent MyApp.app MyApp-mac.zip
|
||||
```
|
||||
|
||||
Windows
|
||||
- Zip the folder containing your `.exe` and runtime files, or create an installer and zip the installer.
|
||||
|
||||
```powershell
|
||||
Compress-Archive -Path .\build\MyApp\* -DestinationPath MyApp-windows.zip
|
||||
```
|
||||
|
||||
Linux
|
||||
- Create a tarball (or zip) of the Linux runtime files:
|
||||
|
||||
```bash
|
||||
tar -czf MyApp-linux.tar.gz -C build/linux .
|
||||
```
|
||||
|
||||
Note: ensure the main binary has executable permissions before archiving.
|
||||
|
||||
## Push to itch.io
|
||||
|
||||
Basic command:
|
||||
|
||||
```bash
|
||||
butler push <path> <username>/<game>:<channel>
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
# macOS build
|
||||
butler push MyApp-mac.zip myuser/nebulabrowser:mac
|
||||
|
||||
# Windows build
|
||||
butler push MyApp-windows.zip myuser/nebulabrowser:windows
|
||||
|
||||
# Linux build
|
||||
butler push MyApp-linux.tar.gz myuser/nebulabrowser:linux
|
||||
```
|
||||
|
||||
Set a release version for itch.io using `--userversion`:
|
||||
|
||||
```bash
|
||||
butler push MyApp-mac.zip myuser/nebulabrowser:mac --userversion 1.2.3
|
||||
```
|
||||
|
||||
## Recommended channel strategy
|
||||
|
||||
- `stable` or `default` — production releases
|
||||
- `beta` — pre-release testing
|
||||
- Use platform-specific channels (e.g., `mac`, `windows`, `linux`) if you want separate channels per OS
|
||||
|
||||
## Tips
|
||||
|
||||
- Keep artifacts small and platform-specific to reduce download size.
|
||||
- Verify the upload with `butler whoami` and by visiting your game page on itch.io.
|
||||
- When testing on macOS, notarization and Gatekeeper may affect distribution; provide clear install instructions on your itch page.
|
||||
|
||||
## Rollback
|
||||
|
||||
Butler supports pushing to a channel multiple times; the latest pushed build becomes the current for that channel. To revert, push a previous artifact or use the itch.io web UI to select a previous build.
|
||||
|
||||
## References
|
||||
|
||||
Consult the official Butler documentation for advanced usage (credentials, automated CI uploads, delta uploads, and platform-specific packaging recommendations).
|
||||
@@ -1,94 +0,0 @@
|
||||
# Electron Upgrade Feature - Bug Fixes
|
||||
|
||||
## Problem Identified
|
||||
The upgrade feature was downloading and installing new Electron versions successfully, but the app always showed the old version (1.0.0) after restart because:
|
||||
|
||||
1. **Version Source Issue**: The app was reading `app.getVersion()` which gets the version from `package.json` at startup time
|
||||
2. **Package.json Not Re-read**: Even after npm installed a new Electron version, the app didn't re-read the updated `package.json`
|
||||
3. **Runtime Display**: The About tab showed the bundled Electron version (37.x) which is baked into the binary at build time
|
||||
|
||||
## Solutions Implemented
|
||||
|
||||
### 1. **New Helper Function: `getInstalledElectronVersion()`**
|
||||
- Reads `package.json` directly every time it's called (not cached)
|
||||
- Extracts the actual installed Electron version from `devDependencies`
|
||||
- Handles both stable (`electron`) and nightly (`electron-nightly`) packages
|
||||
- Strips version specifiers (^, ~, etc.) to get the clean version number
|
||||
- Falls back to `app.getVersion()` if reading fails
|
||||
|
||||
```javascript
|
||||
function getInstalledElectronVersion() {
|
||||
try {
|
||||
const packageJsonPath = path.join(__dirname, 'package.json');
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
|
||||
const electronDep = packageJson.devDependencies?.electron;
|
||||
const electronNightlyDep = packageJson.devDependencies?.['electron-nightly'];
|
||||
|
||||
if (electronDep) {
|
||||
return electronDep.replace(/^\D+/, '');
|
||||
}
|
||||
if (electronNightlyDep) {
|
||||
return electronNightlyDep.replace(/^\D+/, '');
|
||||
}
|
||||
return app.getVersion();
|
||||
} catch (err) {
|
||||
return app.getVersion();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. **Updated `get-electron-versions` Handler**
|
||||
- Now uses `getInstalledElectronVersion()` instead of `app.getVersion()`
|
||||
- Returns the actual installed version that was modified by npm
|
||||
- Performs fresh version checks each time (no caching)
|
||||
|
||||
### 3. **Improved `upgrade-electron` Handler**
|
||||
- Increased `maxBuffer` to handle large npm output
|
||||
- Added cleanup logic to remove the old Electron variant when switching types
|
||||
- Removes `electron` when upgrading to `nightly`
|
||||
- Removes `electron-nightly` when upgrading to `stable`
|
||||
- Better error logging to debug npm failures
|
||||
- Returns clearer messages about installation status
|
||||
|
||||
### 4. **Enhanced UI/UX in settings.js**
|
||||
- Added more descriptive status text ("Downloading and installing..." instead of just "Upgrading...")
|
||||
- Disables all controls during upgrade to prevent multiple clicks
|
||||
- Reduced restart delay from 2000ms to 1500ms for faster feedback
|
||||
- Better error handling with proper cleanup of disabled states
|
||||
|
||||
## How It Works Now
|
||||
|
||||
1. **User clicks "Check for Updates"**
|
||||
- Queries npm registry for latest version
|
||||
- Uses `getInstalledElectronVersion()` to read current version from `package.json`
|
||||
- Compares versions and shows if update is available
|
||||
|
||||
2. **User clicks "Upgrade Electron"**
|
||||
- Confirms action
|
||||
- Runs `npm install --save-dev electron@latest` (or `electron-nightly@latest`)
|
||||
- npm downloads and installs new version
|
||||
- Handler removes the other Electron variant from `package.json` if needed
|
||||
- Shows success message
|
||||
|
||||
3. **App Restarts**
|
||||
- Uses `app.relaunch()` and `app.quit()`
|
||||
- When app relaunches, it:
|
||||
- Loads new Electron binary from `node_modules`
|
||||
- Runs new Electron version
|
||||
- Settings page shows correct new version on next check
|
||||
|
||||
## Testing Recommendations
|
||||
|
||||
1. Test upgrading from stable to nightly version
|
||||
2. Test upgrading from nightly back to stable
|
||||
3. Verify version display updates after restart
|
||||
4. Check that old variant is removed from `package.json`
|
||||
5. Verify app runs stably with new Electron version
|
||||
|
||||
## Notes for Future Development
|
||||
|
||||
- The About tab displays `process.versions.electron` which is the bundled Chromium version, not the Electron framework version
|
||||
- The Electron version we display in the upgrade section comes from `package.json` which is the actual framework version
|
||||
- When building with electron-builder, the bundled version becomes fixed until next rebuild
|
||||
- For development/testing, the upgrade feature reads live from `package.json`
|
||||
@@ -1,3 +0,0 @@
|
||||
# GPU-FIX-README (archived)
|
||||
|
||||
This file was present at repository root and has been archived here for reference. The canonical GPU fix documentation lives under `documentation/`.
|
||||
@@ -1,98 +0,0 @@
|
||||
# Nebula Plugins (Early Preview)
|
||||
|
||||
This document explains how to build simple plugins for Nebula. The initial API is intentionally small and will grow with feedback.
|
||||
|
||||
## Overview
|
||||
|
||||
- Plugins live under either of these folders:
|
||||
- App folder: `<app>/plugins/<plugin-id>/`
|
||||
- User folder: `%APPDATA%/Nebula/plugins/<plugin-id>/` (Windows) – preferred for user-installed plugins.
|
||||
- Each plugin has a `plugin.json` manifest. Optional `main.js` runs in the main process. Optional `renderer-preload.js` runs in the renderer preload context and can expose safe APIs via `contextBridge`.
|
||||
- Plugins are loaded on app start. Toggle a plugin by setting `"enabled": false` in its manifest.
|
||||
|
||||
## Manifest (plugin.json)
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my-plugin",
|
||||
"name": "My Plugin",
|
||||
"version": "0.1.0",
|
||||
"description": "What it does",
|
||||
"main": "main.js",
|
||||
"rendererPreload": "renderer-preload.js",
|
||||
"categories": ["Search", "Productivity"],
|
||||
"authors": ["Jane Doe", { "name": "Acme Labs", "email": "oss@acme.example" }],
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
Fields:
|
||||
- id: Unique id. Defaults to folder name if omitted.
|
||||
- main: Optional entry for main process integration.
|
||||
- rendererPreload: Optional file injected into the preload. Use it to expose a safe surface to the page.
|
||||
- categories: Optional string or array of strings used for organizing/filtering plugins in UI and APIs. Example: ["AI", "Utilities"].
|
||||
- authors: Optional string or array of strings/objects describing authors. Objects support { name, email, url }. In APIs/UI, names are displayed.
|
||||
- enabled: Defaults to true.
|
||||
|
||||
## Main process API (activate)
|
||||
|
||||
If `main` is present, export an `activate(ctx)` function. The `ctx` contains:
|
||||
- Electron: `app`, `BrowserWindow`, `ipcMain`, `session`, `Menu`, `dialog`, `shell`
|
||||
- paths: `{ appPath, userData, pluginDir }`
|
||||
- log/warn/error: prefix logs with your plugin id
|
||||
- on(event, cb): subscribe to lifecycle events (experimental)
|
||||
- registerIPC(channel, handler): quickly expose an `ipcMain.handle`
|
||||
- registerWebRequest(filter, listener): attach `session.webRequest.onBeforeRequest`
|
||||
|
||||
Example:
|
||||
|
||||
module.exports.activate = (ctx) => {
|
||||
ctx.log('hello');
|
||||
ctx.registerIPC('my-plugin:do', async (_evt, payload) => ({ ok: true }));
|
||||
ctx.registerWebRequest({ urls: ['*://*/*'] }, (details) => ({ cancel: false }));
|
||||
};
|
||||
|
||||
## Renderer preload API
|
||||
|
||||
If `rendererPreload` is present, it will be `require()`-d from the app preload. You can use `contextBridge` to expose a safe surface to the page:
|
||||
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
contextBridge.exposeInMainWorld('myPlugin', {
|
||||
hello: () => ipcRenderer.invoke('my-plugin:do'),
|
||||
});
|
||||
|
||||
Your exposed API will be available on `window.myPlugin` in `renderer/` code (e.g., `script.js`).
|
||||
|
||||
## Sample plugin
|
||||
|
||||
A working sample is included at `plugins/sample-hello/`:
|
||||
- Adds menu item "Say Hello (Sample Plugin)" under Help.
|
||||
- Exposes `window.sampleHello.ping()` and `window.sampleHello.onHello(cb)`.
|
||||
|
||||
Try it from the DevTools console:
|
||||
|
||||
await window.sampleHello.ping();
|
||||
window.sampleHello.onHello((m) => console.log('got hello', m));
|
||||
|
||||
Click Help -> Say Hello (Sample Plugin) to see the message delivered to the page.
|
||||
|
||||
## Loading order and safety
|
||||
|
||||
- Plugins load after the app is ready. Renderer preloads run after Nebula's own preload has exposed its APIs.
|
||||
- Context isolation stays enabled. Only data explicitly exposed via `contextBridge` is available to pages.
|
||||
- Avoid long blocking work in plugin activation.
|
||||
|
||||
## Debugging
|
||||
|
||||
- See logs with a `[Plugin:<id>]` prefix in the app console.
|
||||
- Temporarily disable a plugin by setting `enabled: false` in `plugin.json`.
|
||||
|
||||
## Roadmap
|
||||
|
||||
This is a first pass. Planned next:
|
||||
- Enable plugin settings UI
|
||||
- Hot reload/reload button
|
||||
- More lifecycle hooks (tab events, context menu contributions)
|
||||
- Theming hooks
|
||||
@@ -1,238 +0,0 @@
|
||||
Converting extracted AppImage (`squashfs-root`) into a distributable AppDir for Steam
|
||||
|
||||
> Note: Nebula will not be distributed on the Steam Store. This document is
|
||||
> kept for reference and for users who run Nebula via Steam as a non-Steam
|
||||
> shortcut. Official distribution will be handled via itch.io and other
|
||||
> non-Steam channels.
|
||||
|
||||
If your environment lacks `rsync`, use `cp -a` to copy the extracted AppImage into a clean AppDir and prepare it for upload to Steam.
|
||||
|
||||
1) Copy the extracted AppImage to an AppDir folder
|
||||
```bash
|
||||
cp -a squashfs-root/ nebula-appdir
|
||||
```
|
||||
|
||||
2) Unpack `app.asar` to edit or include app sources (optional; requires `npx asar`)
|
||||
```bash
|
||||
cd nebula-appdir/resources
|
||||
npx asar extract app.asar app
|
||||
# keep a backup if you want
|
||||
mv app app.orig && rm app.asar
|
||||
cd ../../
|
||||
```
|
||||
|
||||
3) Add/verify launcher (we added `nebula-appdir/Nebula`):
|
||||
```bash
|
||||
chmod +x nebula-appdir/Nebula
|
||||
```
|
||||
Run locally:
|
||||
```bash
|
||||
cd nebula-appdir
|
||||
./Nebula
|
||||
```
|
||||
|
||||
4) Ensure binary & permissions are correct
|
||||
```bash
|
||||
chmod +x nebula-appdir/nebula
|
||||
```
|
||||
|
||||
5) Package or upload to Steam
|
||||
- Create a tarball to upload as game files, or upload the AppDir contents as the depot.
|
||||
```bash
|
||||
tar -czf nebula-appdir.tar.gz -C nebula-appdir .
|
||||
```
|
||||
- In Steamworks, set the launch command to `./Nebula` (or `./nebula`).
|
||||
|
||||
Notes
|
||||
- `--no-sandbox` reduces Chromium sandboxing; prefer fixing `chrome-sandbox` and enabling sandboxing when possible.
|
||||
- Using the AppDir avoids AppImage/FUSE dependency on target systems.
|
||||
- Test on a clean SteamOS/Deck image before publishing.
|
||||
|
||||
Big Picture auto-start (SteamOS Gaming Mode)
|
||||
- If Nebula is launched from SteamOS Gaming Mode, it will auto-start in Big Picture Mode.
|
||||
- To force/disable via Steam Launch Options: `--big-picture` or `--no-big-picture`.
|
||||
|
||||
---
|
||||
|
||||
## Built-in Controller Support (Steam Deck / Game Mode)
|
||||
|
||||
Nebula has **native gamepad support** that signals to Steam that the application is consuming controller input. This prevents Steam from applying Desktop mouse/keyboard emulation when running in Game Mode.
|
||||
|
||||
### How It Works
|
||||
|
||||
Steam Deck only stops applying Desktop mouse emulation when:
|
||||
1. The application actively reads controller/gamepad input, OR
|
||||
2. Steam Input is enabled (which requires explicit configuration)
|
||||
|
||||
If an app does not read controller input at all, Steam assumes the user needs mouse emulation.
|
||||
|
||||
Nebula solves this by:
|
||||
1. **Preload Gamepad Handler**: The preload script (`preload.js`) continuously polls `navigator.getGamepads()` from the moment any window loads. This signals to Steam that the app is consuming gamepad events and should not apply mouse emulation.
|
||||
2. **Big Picture Mode**: Full controller-friendly UI with:
|
||||
- D-pad / Left stick: Navigate menus
|
||||
- A button: Select/activate
|
||||
- B button: Back
|
||||
- X button: Backspace (in keyboard)
|
||||
- Y button: Space / Open search
|
||||
- LB/RB: Navigate webview history
|
||||
- Right stick: Virtual cursor (in browse mode)
|
||||
- Triggers: Left/right click (in browse mode)
|
||||
- Start: Toggle settings/sidebar
|
||||
- Select: Toggle fullscreen webview
|
||||
|
||||
### Gamepad API (for Developers)
|
||||
|
||||
The gamepad handler exposes an API via `window.gamepadAPI`.
|
||||
|
||||
```javascript
|
||||
// Check if gamepad handler is initialized
|
||||
if (gamepadAPI.isAvailable()) {
|
||||
console.log('Gamepad handler is running');
|
||||
}
|
||||
|
||||
// Check if a gamepad is connected
|
||||
if (gamepadAPI.isConnected()) {
|
||||
console.log('Gamepad connected!');
|
||||
}
|
||||
|
||||
// Get list of connected gamepads
|
||||
const gamepads = gamepadAPI.getConnected();
|
||||
// Returns: [{ id, index, mapping, buttons, axes }, ...]
|
||||
console.log(gamepads);
|
||||
|
||||
// Get active gamepad's current state (buttons and axes)
|
||||
const active = gamepadAPI.getActive();
|
||||
if (active) {
|
||||
console.log('Active gamepad:', active.id);
|
||||
console.log('Buttons:', active.buttons);
|
||||
console.log('Axes:', active.axes);
|
||||
}
|
||||
|
||||
// Get handler state for debugging
|
||||
const state = gamepadAPI.getState();
|
||||
console.log('Handler state:', state);
|
||||
// Returns: { initialized, connectedCount, activeGamepadIndex, isPolling }
|
||||
|
||||
// Listen for gamepad events (via CustomEvent on window)
|
||||
window.addEventListener('nebula-gamepad-button', (e) => {
|
||||
const { button, pressed, value } = e.detail;
|
||||
console.log(`Button ${button}: ${pressed ? 'pressed' : 'released'}`);
|
||||
});
|
||||
|
||||
window.addEventListener('nebula-gamepad-connect', (e) => {
|
||||
console.log('Gamepad connected:', e.detail.id);
|
||||
});
|
||||
|
||||
window.addEventListener('nebula-gamepad-disconnect', (e) => {
|
||||
console.log('Gamepad disconnected:', e.detail.id);
|
||||
});
|
||||
|
||||
window.addEventListener('nebula-gamepad-axis', (e) => {
|
||||
const { axis, value } = e.detail;
|
||||
console.log(`Axis ${axis}: ${value}`);
|
||||
});
|
||||
|
||||
// Enable debug logging
|
||||
gamepadAPI.setDebug(true);
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
If Steam is still applying mouse emulation:
|
||||
|
||||
1. **Configure Steam Input per-game** (most reliable fix):
|
||||
- **Windows / Desktop Steam UI**:
|
||||
- Library → right-click Nebula → Properties → **Controller**
|
||||
- Set **"Override for Nebula"** to **"Disable Steam Input"**
|
||||
- **Steam Deck / SteamOS Gaming Mode**:
|
||||
- Open Nebula → press the Steam button → **Controller Settings** (or the controller icon)
|
||||
- Set the layout to a **Gamepad** template (not “Keyboard/Mouse”), or disable Steam Input if the toggle is available
|
||||
|
||||
If you **don’t see a Controller tab** (common when the Steam entry is treated as an “application/tool”):
|
||||
- Use **Big Picture / Gaming Mode** and edit the **Controller Layout** for that specific entry.
|
||||
- Or change Steam’s global Desktop Layout: Steam → Settings → Controller → **Desktop Layout** → pick a gamepad-focused template or remove mouse/keyboard bindings.
|
||||
|
||||
2. **Verify gamepad polling is active**: Open DevTools (F12) and run `gamepadAPI.getState()` - check that `isPolling` is `true`
|
||||
3. **Check gamepad connection**: Run `gamepadAPI.getConnected()` to see detected gamepads
|
||||
4. **Press a button first**: On Linux, the `gamepadconnected` event may not fire until the first button press
|
||||
5. **Enable debug mode**: Run `gamepadAPI.setDebug(true)` to see detailed logs
|
||||
6. **Restart the app**: Close Nebula completely and relaunch from Steam
|
||||
|
||||
### Steam Launch Options
|
||||
|
||||
#### Windows
|
||||
|
||||
The `VAR=value %command%` syntax does **not** work on Windows. Use the Steam UI instead:
|
||||
|
||||
1. **Library** → right-click Nebula → **Properties** → **Controller** → set to **"Disable Steam Input"**
|
||||
2. If no Controller tab exists, open Steam in **Big Picture Mode** → Nebula → **Manage Game** (gear) → **Controller Options** → **Disable Steam Input**
|
||||
|
||||
If you must use launch options on Windows, use this wrapper syntax:
|
||||
```bat
|
||||
cmd /c "set SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD=0 && %command%"
|
||||
```
|
||||
|
||||
#### Linux / SteamOS / Steam Deck
|
||||
|
||||
Add these to your Steam launch options (Right-click game → Properties → Launch Options):
|
||||
|
||||
```bash
|
||||
# Disable Steam Input completely (recommended for native controller support)
|
||||
SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD=0 %command%
|
||||
|
||||
# Force native gamepad without Steam's emulation layer
|
||||
STEAM_INPUT_ENABLE_VIRTUAL_GAMEPAD=0 %command%
|
||||
|
||||
# Combined - full native controller mode with Big Picture UI
|
||||
SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD=0 STEAM_INPUT_ENABLE_VIRTUAL_GAMEPAD=0 %command% --big-picture
|
||||
|
||||
# If you need to debug controller issues
|
||||
SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD=0 %command% --big-picture 2>&1 | tee ~/nebula-debug.log
|
||||
```
|
||||
|
||||
### Steam Deck Recommended Setup
|
||||
|
||||
For the best experience on Steam Deck:
|
||||
|
||||
1. **Add Nebula as a Non-Steam Game** (if not using Steamworks version)
|
||||
2. **Controller Settings**:
|
||||
- Right-click Nebula → Properties → Controller
|
||||
- Set to **"Disable Steam Input"**
|
||||
3. **Launch Options**:
|
||||
```
|
||||
SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD=0 STEAM_INPUT_ENABLE_VIRTUAL_GAMEPAD=0 %command% --big-picture
|
||||
```
|
||||
4. **Shortcuts** (optional):
|
||||
- Configure gamepad shortcuts in Steam for Steam button actions (screenshots, etc.)
|
||||
|
||||
### Why This Is Needed
|
||||
|
||||
Steam Deck / SteamOS Game Mode applies "Desktop Configuration" mouse/keyboard emulation to apps that don't appear to handle controller input. Even though Nebula polls `navigator.getGamepads()` continuously, Steam's input layer initializes before the app can signal its intent.
|
||||
|
||||
The solution is two-fold:
|
||||
1. **Environment variables** (`SDL_GAMECONTROLLER_*`) signal to Steam's SDL-based input layer early
|
||||
2. **Steam Input settings** ("Disable Steam Input") bypasses the emulation entirely
|
||||
|
||||
### Shipping Defaults (Steamworks “Software/App” limitation)
|
||||
|
||||
If your Steamworks package is categorized as **Software/Application**, Steamworks may not expose per-title Steam Input configuration the way it does for games.
|
||||
|
||||
In that case:
|
||||
- You generally **cannot force a global Steam Input toggle** for all users from Steamworks.
|
||||
- The practical, shippable approach is to (a) **consume controller input natively** (Nebula does this via early Gamepad API polling) so Steam Deck/Game Mode backs off Desktop emulation, and (b) provide user-facing guidance for disabling Steam Input / choosing a Gamepad layout.
|
||||
|
||||
If you need Steam Input defaults controlled centrally, the usual path is to ask Valve Partner Support to enable the relevant Steam Input configuration for your App ID, or to re-categorize the title where appropriate.
|
||||
|
||||
### Force Big Picture Mode
|
||||
|
||||
```bash
|
||||
# Via command line
|
||||
./Nebula --big-picture
|
||||
|
||||
# Via environment
|
||||
NEBULA_BIG_PICTURE=1 ./Nebula
|
||||
|
||||
# Disable Big Picture Mode
|
||||
./Nebula --no-big-picture
|
||||
NEBULA_NO_BIG_PICTURE=1 ./Nebula
|
||||
```
|
||||
@@ -1,212 +0,0 @@
|
||||
# Linux / SteamOS Build Upload Guide (SteamCMD)
|
||||
|
||||
> Note: Nebula will not be distributed on the Steam Store. This Steam upload
|
||||
> guide is retained for historical/reference purposes only. Official releases
|
||||
> will be published on itch.io and other non-Steam channels.
|
||||
|
||||
This guide explains how to upload the **Linux / SteamOS** build of Nebula Browser to Steam using **SteamCMD**. It is tailored to the current project layout on Steam Deck / Linux.
|
||||
|
||||
---
|
||||
|
||||
## App & Depot IDs
|
||||
|
||||
* **App ID:** 4290110
|
||||
* **Windows Depot:** 4290111
|
||||
* **Linux / SteamOS Depot:** 4290112
|
||||
|
||||
This guide covers **Linux only (4290112)**.
|
||||
|
||||
---
|
||||
|
||||
## Current Folder Layout
|
||||
|
||||
Home directory layout expected by this guide:
|
||||
|
||||
```
|
||||
/home/deck/
|
||||
├── steamcmd/
|
||||
│ └── steamcmd.sh
|
||||
└── steam_build/
|
||||
├── Nebula_SteamOS/
|
||||
│ ├── nebula-browser
|
||||
│ └── ...other runtime files...
|
||||
├── output/
|
||||
└── app_4290110.vdf
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Verify Executable Permissions
|
||||
|
||||
The main Linux binary **must** be executable.
|
||||
|
||||
```bash
|
||||
cd ~/steam_build/Nebula_SteamOS
|
||||
ls -la
|
||||
```
|
||||
|
||||
Identify the main binary (for example `nebula-browser`) and run:
|
||||
|
||||
```bash
|
||||
chmod +x nebula-browser
|
||||
```
|
||||
|
||||
Optional quick test:
|
||||
|
||||
```bash
|
||||
./nebula-browser
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: App Build VDF Configuration
|
||||
|
||||
Ensure `app_4290110.vdf` exists at:
|
||||
|
||||
```
|
||||
/home/deck/steam_build/app_4290110.vdf
|
||||
```
|
||||
|
||||
Its contents should be **exactly**:
|
||||
|
||||
```
|
||||
"AppBuild"
|
||||
{
|
||||
"AppID" "4290110"
|
||||
"Desc" "Nebula SteamOS build"
|
||||
"BuildOutput" "output/"
|
||||
"ContentRoot" "."
|
||||
"Preview" "0"
|
||||
|
||||
"Depots"
|
||||
{
|
||||
"4290112"
|
||||
{
|
||||
"FileMapping"
|
||||
{
|
||||
"LocalPath" "Nebula_SteamOS/*"
|
||||
"DepotPath" "."
|
||||
"recursive" "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
* `ContentRoot "."` maps to `/home/deck/steam_build`
|
||||
* `Nebula_SteamOS/*` uploads everything inside that folder
|
||||
* Depot **4290112** is the Linux / SteamOS depot
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Launch SteamCMD
|
||||
|
||||
```bash
|
||||
cd ~/steamcmd
|
||||
./steamcmd.sh
|
||||
```
|
||||
|
||||
You should now see:
|
||||
|
||||
```
|
||||
Steam>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Log In
|
||||
|
||||
Inside SteamCMD:
|
||||
|
||||
```
|
||||
login YOUR_STEAM_USERNAME
|
||||
```
|
||||
|
||||
If Steam Guard is enabled, enter the code when prompted.
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Upload the Linux Build
|
||||
|
||||
Inside SteamCMD, run **with full path** (no `~`):
|
||||
|
||||
```
|
||||
run_app_build /home/deck/steam_build/app_4290110.vdf
|
||||
```
|
||||
|
||||
Expected behavior:
|
||||
|
||||
* Content scan starts
|
||||
* Files upload to depot 4290112
|
||||
* A **BuildID** is printed on success
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Exit SteamCMD
|
||||
|
||||
```
|
||||
quit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 7: Assign Build to a Branch (Required)
|
||||
|
||||
After upload completes:
|
||||
|
||||
1. Open **Steamworks**
|
||||
2. Go to **Nebula Browser → Builds**
|
||||
3. Find the new Build ID
|
||||
4. Assign it to a branch (`internal`, `beta`, or `default`)
|
||||
|
||||
If this step is skipped, Steam Deck installs **0 bytes** even though upload succeeded.
|
||||
|
||||
---
|
||||
|
||||
## Common Issues
|
||||
|
||||
### App build file does not exist
|
||||
|
||||
* SteamCMD does **not** expand `~`
|
||||
* Always use `/home/deck/...` absolute paths
|
||||
|
||||
### 0 bytes uploaded
|
||||
|
||||
* `LocalPath` is wrong
|
||||
* Build not assigned to a branch
|
||||
* Executable missing or filtered out
|
||||
|
||||
### Build installs but does not launch
|
||||
|
||||
* Binary not executable
|
||||
* Missing runtime libraries
|
||||
* Incorrect launch configuration in Steamworks
|
||||
|
||||
---
|
||||
|
||||
## Logs
|
||||
|
||||
If something fails, check:
|
||||
|
||||
```bash
|
||||
tail -n 200 /home/deck/.local/share/Steam/logs/stderr.txt
|
||||
tail -n 200 /home/deck/.local/share/Steam/logs/bootstrap_log.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes for Steam Deck
|
||||
|
||||
* Test in **Desktop Mode** first
|
||||
* Then test in **Gaming Mode**
|
||||
* Steam Input and sandboxing differ between modes
|
||||
* Avoid absolute paths inside the app
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
This process uploads **Linux / SteamOS depot 4290112 only**.
|
||||
Windows builds should be uploaded separately using depot **4290111**.
|
||||
@@ -1,133 +0,0 @@
|
||||
# Nebot Typing Animation Feature
|
||||
|
||||
## Overview
|
||||
Added a realistic typing animation to the Nebot chat interface that makes AI responses appear character by character, similar to ChatGPT and other modern AI chat interfaces.
|
||||
|
||||
## Features Added
|
||||
|
||||
### 1. **Typing Animation**
|
||||
- Characters appear one by one instead of instantly
|
||||
- Smooth, natural typing rhythm
|
||||
- Configurable typing speed
|
||||
- Blinking cursor indicator during typing
|
||||
|
||||
### 2. **Settings Integration**
|
||||
- **Enable/Disable Toggle**: Users can turn typing animation on/off
|
||||
- **Speed Control**: Adjustable from 10-200 characters per second
|
||||
- **Live Preview**: Speed indicator updates in real-time
|
||||
- **Persistent Settings**: Preferences are saved and restored
|
||||
|
||||
### 3. **Smart Behavior**
|
||||
- **Queue Management**: Handles fast token streams efficiently
|
||||
- **Graceful Fallback**: Falls back to instant display if disabled
|
||||
- **Markdown Rendering**: Waits for typing to complete before rendering markdown
|
||||
- **Auto-scroll**: Maintains scroll position during animation
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Code Changes Made:
|
||||
|
||||
#### 1. **page.js** - Main Logic
|
||||
```javascript
|
||||
// Typing animation state
|
||||
let typingQueue = [];
|
||||
let isTyping = false;
|
||||
let typingSpeed = 25; // milliseconds per character
|
||||
let typingEnabled = true; // can be toggled in settings
|
||||
|
||||
function startTypingAnimation(element) {
|
||||
if (isTyping || typingQueue.length === 0) return;
|
||||
|
||||
isTyping = true;
|
||||
element.classList.add('typing');
|
||||
|
||||
function typeNext() {
|
||||
if (typingQueue.length === 0) {
|
||||
isTyping = false;
|
||||
element.classList.remove('typing');
|
||||
return;
|
||||
}
|
||||
|
||||
const char = typingQueue.shift();
|
||||
element.textContent += char;
|
||||
els.messages.scrollTop = els.messages.scrollHeight;
|
||||
|
||||
setTimeout(typeNext, typingSpeed);
|
||||
}
|
||||
|
||||
typeNext();
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. **page.css** - Visual Effects
|
||||
```css
|
||||
/* Typing animation cursor */
|
||||
.markdown.typing:after {
|
||||
content: "▋";
|
||||
color: var(--accent);
|
||||
animation: blink 1s infinite;
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. **Settings UI** - User Controls
|
||||
- Checkbox to enable/disable typing animation
|
||||
- Range slider for speed control (10-200 chars/sec)
|
||||
- Real-time speed display
|
||||
- Proper styling for form elements
|
||||
|
||||
## User Experience
|
||||
|
||||
### Before:
|
||||
- Text appeared instantly when AI responded
|
||||
- No visual feedback during response generation
|
||||
- Less engaging interaction
|
||||
|
||||
### After:
|
||||
- Smooth character-by-character reveal
|
||||
- Blinking cursor shows active typing
|
||||
- Configurable speed for user preference
|
||||
- More engaging, human-like interaction
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
1. **Open Nebot**: Navigate to the Nebot page in Nebula Browser
|
||||
2. **Start a Chat**: Send a message to begin conversation
|
||||
3. **Watch the Animation**: AI responses will type out naturally
|
||||
4. **Customize Settings**:
|
||||
- Click the ⚙ Settings button
|
||||
- Toggle "Enable typing animation"
|
||||
- Adjust typing speed with the slider
|
||||
- Save changes
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- **Efficient Queuing**: Uses character queue to handle fast token streams
|
||||
- **Memory Friendly**: Minimal memory overhead
|
||||
- **Responsive**: Maintains smooth UI during animation
|
||||
- **Interruptible**: Can be disabled without restart
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements could include:
|
||||
- Variable speed based on punctuation (pause at periods)
|
||||
- Sound effects for typing
|
||||
- Different animation styles
|
||||
- Per-conversation speed settings
|
||||
- Typing speed based on message length
|
||||
|
||||
## Testing
|
||||
|
||||
To test the feature:
|
||||
1. Start Nebula Browser (`npm start`)
|
||||
2. Navigate to Nebot page
|
||||
3. Send a message and observe the typing animation
|
||||
4. Try different speed settings in the settings panel
|
||||
5. Toggle the feature on/off to compare experiences
|
||||
|
||||
The typing animation enhances the user experience by making AI interactions feel more natural and engaging, similar to popular chat interfaces like ChatGPT.
|
||||
@@ -1,53 +0,0 @@
|
||||
# Google OAuth Sign-in Debug Guide
|
||||
|
||||
## Changes Made to Fix Google Sign-in Issues
|
||||
|
||||
### 1. User Agent Strategy
|
||||
- Nebula removes the default Electron token from the UA and appends `Nebula/<version>` for better compatibility while still identifying the app.
|
||||
- The UA is applied at the session level (main/default sessions) so all tabs/webviews inherit it.
|
||||
- To debug with Electron visible in UA, set environment variable `NEBULA_DEBUG_ELECTRON_UA=1` before launch.
|
||||
|
||||
### 2. Webview and Window Behavior
|
||||
- Webviews inherit secure defaults from `webPreferences`.
|
||||
- Popup windows opened by sites (e.g., OAuth) are allowed for `http`/`https` URLs to preserve login flows.
|
||||
|
||||
### 3. Session Configuration for OAuth
|
||||
- Configured session permissions for OAuth compatibility.
|
||||
- Added cookie change monitoring for Google domains.
|
||||
- Enhanced request headers (Accept-Language, Accept) and `Referrer-Policy` for OAuth endpoints.
|
||||
|
||||
### 4. Unified Session Partitioning
|
||||
- The main window uses partition `persist:main`, and sessions are configured consistently so auth/session state is shared across tabs.
|
||||
|
||||
## Testing Google Sign-in
|
||||
|
||||
1. **Open the browser** (already running)
|
||||
2. **Navigate to** any Google service (Gmail, YouTube, Drive, etc.)
|
||||
3. **Click Sign In** - you should now see the Google account picker
|
||||
4. **Select your account** - should take you to password/2FA screen
|
||||
5. **Complete sign-in** - should successfully sign you in
|
||||
|
||||
Note: POST-based navigations are not blocked or intercepted by the main process to avoid stripping request bodies.
|
||||
|
||||
## Debug Information
|
||||
|
||||
If issues persist, check the Console (F12) for:
|
||||
- Cookie changes for Google domains
|
||||
- OAuth redirect flows
|
||||
- JavaScript errors
|
||||
|
||||
## Common OAuth Issues Fixed
|
||||
|
||||
- ✅ Missing User Agent (Google blocks unidentified browsers)
|
||||
- ✅ Third-party cookie restrictions
|
||||
- ✅ Session isolation between tabs
|
||||
- ✅ Missing referrer policies
|
||||
- ✅ Popup blocking for OAuth flows
|
||||
|
||||
## What Should Work Now
|
||||
|
||||
- Google account picker should appear
|
||||
- Password entry screens should load
|
||||
- Two-factor authentication should work
|
||||
- OAuth redirects should complete properly
|
||||
- Session should persist across tabs
|
||||
@@ -1,453 +0,0 @@
|
||||
/**
|
||||
* Nebula Browser - Global Gamepad Input Handler (Standalone Reference)
|
||||
*
|
||||
* NOTE: This is a standalone reference implementation. The actual gamepad handler
|
||||
* used by Nebula is integrated directly into preload.js for proper context isolation
|
||||
* compatibility. This file is kept for reference and potential future use.
|
||||
*
|
||||
* This module actively polls and consumes gamepad input from the Gamepad API.
|
||||
* This is CRITICAL for Steam Deck/SteamOS Game Mode:
|
||||
*
|
||||
* Steam only stops applying Desktop mouse emulation when:
|
||||
* - The application actively reads controller/gamepad input, OR
|
||||
* - Steam Input is enabled (which requires explicit configuration)
|
||||
*
|
||||
* If the app does not read controller input at all, Steam assumes the user
|
||||
* needs mouse emulation. By continuously polling navigator.getGamepads(),
|
||||
* Steam recognizes that the app is consuming gamepad events and backs off
|
||||
* the Desktop mouse emulation layer.
|
||||
*
|
||||
* This module should be loaded as early as possible in the renderer process.
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Prevent double initialization
|
||||
if (window.__nebulaGamepadHandler) {
|
||||
return;
|
||||
}
|
||||
|
||||
const CONFIG = {
|
||||
// Polling rate in ms (60fps = ~16ms, we use requestAnimationFrame)
|
||||
POLL_INTERVAL: 16,
|
||||
|
||||
// Deadzone for analog sticks
|
||||
STICK_DEADZONE: 0.15,
|
||||
TRIGGER_DEADZONE: 0.1,
|
||||
|
||||
// Enable debug logging
|
||||
DEBUG: false,
|
||||
};
|
||||
|
||||
// Global state
|
||||
const state = {
|
||||
initialized: false,
|
||||
gamepads: {},
|
||||
connectedCount: 0,
|
||||
activeGamepadIndex: null,
|
||||
lastPollTime: 0,
|
||||
rafId: null,
|
||||
|
||||
// Button states for edge detection
|
||||
buttonStates: {},
|
||||
|
||||
// Callbacks for interested listeners
|
||||
listeners: {
|
||||
connect: [],
|
||||
disconnect: [],
|
||||
button: [],
|
||||
axis: [],
|
||||
input: [], // Any input (for keeping the polling "active")
|
||||
},
|
||||
};
|
||||
|
||||
// Debug logger
|
||||
const log = (...args) => {
|
||||
if (CONFIG.DEBUG) {
|
||||
console.log('[NebulaGamepad]', ...args);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize the gamepad handler.
|
||||
* This should be called as early as possible.
|
||||
*/
|
||||
function init() {
|
||||
if (state.initialized) {
|
||||
log('Already initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof navigator === 'undefined' || !navigator.getGamepads) {
|
||||
console.warn('[NebulaGamepad] Gamepad API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
log('Initializing gamepad handler');
|
||||
|
||||
// Listen for connect/disconnect events
|
||||
window.addEventListener('gamepadconnected', handleGamepadConnected);
|
||||
window.addEventListener('gamepaddisconnected', handleGamepadDisconnected);
|
||||
|
||||
// Do an initial scan for already-connected gamepads
|
||||
// (important for Steam Deck where the controller is always connected)
|
||||
scanGamepads();
|
||||
|
||||
// Start the polling loop immediately
|
||||
// This is the KEY part: continuously polling getGamepads() signals to Steam
|
||||
// that we're actively consuming gamepad input
|
||||
startPolling();
|
||||
|
||||
state.initialized = true;
|
||||
|
||||
log('Gamepad handler initialized');
|
||||
|
||||
// Expose debug info
|
||||
if (CONFIG.DEBUG) {
|
||||
window.__nebulaGamepadDebug = {
|
||||
state,
|
||||
getActiveGamepad,
|
||||
getConnectedGamepads,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle gamepad connection event
|
||||
*/
|
||||
function handleGamepadConnected(event) {
|
||||
const gamepad = event.gamepad;
|
||||
log('Gamepad connected:', gamepad.index, gamepad.id);
|
||||
|
||||
state.gamepads[gamepad.index] = {
|
||||
id: gamepad.id,
|
||||
index: gamepad.index,
|
||||
connected: true,
|
||||
mapping: gamepad.mapping,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
state.connectedCount++;
|
||||
|
||||
// Set as active if we don't have one
|
||||
if (state.activeGamepadIndex === null) {
|
||||
state.activeGamepadIndex = gamepad.index;
|
||||
log('Set active gamepad:', gamepad.index);
|
||||
}
|
||||
|
||||
// Initialize button states for this gamepad
|
||||
state.buttonStates[gamepad.index] = {};
|
||||
|
||||
// Notify listeners
|
||||
emitEvent('connect', { gamepad, index: gamepad.index, id: gamepad.id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle gamepad disconnection event
|
||||
*/
|
||||
function handleGamepadDisconnected(event) {
|
||||
const gamepad = event.gamepad;
|
||||
log('Gamepad disconnected:', gamepad.index, gamepad.id);
|
||||
|
||||
if (state.gamepads[gamepad.index]) {
|
||||
state.gamepads[gamepad.index].connected = false;
|
||||
delete state.gamepads[gamepad.index];
|
||||
state.connectedCount--;
|
||||
}
|
||||
|
||||
// Clear button states
|
||||
delete state.buttonStates[gamepad.index];
|
||||
|
||||
// If this was the active gamepad, find another
|
||||
if (state.activeGamepadIndex === gamepad.index) {
|
||||
state.activeGamepadIndex = null;
|
||||
|
||||
// Try to find another connected gamepad
|
||||
const gamepads = navigator.getGamepads();
|
||||
for (let i = 0; i < gamepads.length; i++) {
|
||||
if (gamepads[i]) {
|
||||
state.activeGamepadIndex = i;
|
||||
log('Switched active gamepad to:', i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notify listeners
|
||||
emitEvent('disconnect', { index: gamepad.index, id: gamepad.id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan for already-connected gamepads
|
||||
* This is important because on Linux/Steam Deck, the gamepadconnected event
|
||||
* may not fire until the first button press
|
||||
*/
|
||||
function scanGamepads() {
|
||||
const gamepads = navigator.getGamepads();
|
||||
|
||||
for (let i = 0; i < gamepads.length; i++) {
|
||||
const gamepad = gamepads[i];
|
||||
if (gamepad && !state.gamepads[gamepad.index]) {
|
||||
log('Found pre-connected gamepad:', gamepad.index, gamepad.id);
|
||||
|
||||
state.gamepads[gamepad.index] = {
|
||||
id: gamepad.id,
|
||||
index: gamepad.index,
|
||||
connected: true,
|
||||
mapping: gamepad.mapping,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
state.connectedCount++;
|
||||
|
||||
if (state.activeGamepadIndex === null) {
|
||||
state.activeGamepadIndex = gamepad.index;
|
||||
}
|
||||
|
||||
state.buttonStates[gamepad.index] = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the gamepad polling loop
|
||||
* Uses requestAnimationFrame for efficient, consistent polling
|
||||
*/
|
||||
function startPolling() {
|
||||
if (state.rafId !== null) {
|
||||
return; // Already polling
|
||||
}
|
||||
|
||||
function pollLoop(timestamp) {
|
||||
state.lastPollTime = timestamp;
|
||||
|
||||
// CRITICAL: This call to getGamepads() is what tells Steam we're
|
||||
// actively consuming gamepad input
|
||||
const gamepads = navigator.getGamepads();
|
||||
|
||||
// Process input from all connected gamepads
|
||||
for (let i = 0; i < gamepads.length; i++) {
|
||||
const gamepad = gamepads[i];
|
||||
if (gamepad) {
|
||||
processGamepadInput(gamepad);
|
||||
}
|
||||
}
|
||||
|
||||
// Also do periodic scans for newly connected gamepads
|
||||
// (handles edge case where event doesn't fire)
|
||||
if (timestamp % 1000 < 20) {
|
||||
scanGamepads();
|
||||
}
|
||||
|
||||
// Continue polling
|
||||
state.rafId = requestAnimationFrame(pollLoop);
|
||||
}
|
||||
|
||||
state.rafId = requestAnimationFrame(pollLoop);
|
||||
log('Started gamepad polling');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the polling loop (called on page unload)
|
||||
*/
|
||||
function stopPolling() {
|
||||
if (state.rafId !== null) {
|
||||
cancelAnimationFrame(state.rafId);
|
||||
state.rafId = null;
|
||||
log('Stopped gamepad polling');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process input from a gamepad
|
||||
*/
|
||||
function processGamepadInput(gamepad) {
|
||||
const index = gamepad.index;
|
||||
const buttonState = state.buttonStates[index] || {};
|
||||
let hasInput = false;
|
||||
|
||||
// Process buttons
|
||||
for (let i = 0; i < gamepad.buttons.length; i++) {
|
||||
const button = gamepad.buttons[i];
|
||||
const wasPressed = buttonState[`b${i}`] || false;
|
||||
const isPressed = button.pressed || button.value > 0.5;
|
||||
|
||||
if (isPressed !== wasPressed) {
|
||||
buttonState[`b${i}`] = isPressed;
|
||||
hasInput = true;
|
||||
|
||||
emitEvent('button', {
|
||||
gamepad,
|
||||
index,
|
||||
button: i,
|
||||
pressed: isPressed,
|
||||
value: button.value,
|
||||
});
|
||||
|
||||
log(`Button ${i}: ${isPressed ? 'pressed' : 'released'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Process axes (analog sticks, triggers)
|
||||
for (let i = 0; i < gamepad.axes.length; i++) {
|
||||
const value = gamepad.axes[i];
|
||||
const prevValue = buttonState[`a${i}`] || 0;
|
||||
|
||||
// Only emit if there's significant change
|
||||
if (Math.abs(value - prevValue) > 0.01) {
|
||||
buttonState[`a${i}`] = value;
|
||||
|
||||
// Check if beyond deadzone
|
||||
if (Math.abs(value) > CONFIG.STICK_DEADZONE) {
|
||||
hasInput = true;
|
||||
|
||||
emitEvent('axis', {
|
||||
gamepad,
|
||||
index,
|
||||
axis: i,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.buttonStates[index] = buttonState;
|
||||
|
||||
// Emit generic input event if any input detected
|
||||
if (hasInput) {
|
||||
emitEvent('input', { gamepad, index });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event to registered listeners
|
||||
*/
|
||||
function emitEvent(type, data) {
|
||||
const listeners = state.listeners[type] || [];
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener(data);
|
||||
} catch (err) {
|
||||
console.error('[NebulaGamepad] Listener error:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a listener for gamepad events
|
||||
* @param {string} type - Event type: 'connect', 'disconnect', 'button', 'axis', 'input'
|
||||
* @param {function} callback - Callback function
|
||||
* @returns {function} Unsubscribe function
|
||||
*/
|
||||
function on(type, callback) {
|
||||
if (!state.listeners[type]) {
|
||||
state.listeners[type] = [];
|
||||
}
|
||||
state.listeners[type].push(callback);
|
||||
|
||||
return () => {
|
||||
const idx = state.listeners[type].indexOf(callback);
|
||||
if (idx !== -1) {
|
||||
state.listeners[type].splice(idx, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently active gamepad
|
||||
* @returns {Gamepad|null}
|
||||
*/
|
||||
function getActiveGamepad() {
|
||||
if (state.activeGamepadIndex === null) {
|
||||
return null;
|
||||
}
|
||||
const gamepads = navigator.getGamepads();
|
||||
return gamepads[state.activeGamepadIndex] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all connected gamepads
|
||||
* @returns {Gamepad[]}
|
||||
*/
|
||||
function getConnectedGamepads() {
|
||||
const gamepads = navigator.getGamepads();
|
||||
return Array.from(gamepads).filter(gp => gp !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any gamepad is connected
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isGamepadConnected() {
|
||||
return state.connectedCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the active gamepad by index
|
||||
* @param {number} index
|
||||
*/
|
||||
function setActiveGamepad(index) {
|
||||
const gamepads = navigator.getGamepads();
|
||||
if (gamepads[index]) {
|
||||
state.activeGamepadIndex = index;
|
||||
log('Active gamepad set to:', index);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
stopPolling();
|
||||
window.removeEventListener('gamepadconnected', handleGamepadConnected);
|
||||
window.removeEventListener('gamepaddisconnected', handleGamepadDisconnected);
|
||||
});
|
||||
|
||||
// Pause polling when page is hidden to save resources
|
||||
// but not for too long - we still want Steam to see we're active
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
// Continue polling but at a slower rate when hidden
|
||||
// We don't stop entirely because Steam needs to see we're consuming input
|
||||
log('Page hidden, continuing polling');
|
||||
} else {
|
||||
log('Page visible');
|
||||
}
|
||||
});
|
||||
|
||||
// Export the API
|
||||
const gamepadHandler = {
|
||||
init,
|
||||
on,
|
||||
getActiveGamepad,
|
||||
getConnectedGamepads,
|
||||
isGamepadConnected,
|
||||
setActiveGamepad,
|
||||
|
||||
// Expose state for debugging
|
||||
get state() {
|
||||
return { ...state, buttonStates: { ...state.buttonStates } };
|
||||
},
|
||||
|
||||
// Config
|
||||
get config() {
|
||||
return { ...CONFIG };
|
||||
},
|
||||
setDebug(enabled) {
|
||||
CONFIG.DEBUG = !!enabled;
|
||||
},
|
||||
};
|
||||
|
||||
// Mark as initialized and expose globally
|
||||
window.__nebulaGamepadHandler = gamepadHandler;
|
||||
|
||||
// Auto-initialize when DOM is ready (or immediately if already loaded)
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
// DOM already loaded, initialize immediately
|
||||
init();
|
||||
}
|
||||
|
||||
log('Gamepad handler module loaded');
|
||||
|
||||
})();
|
||||
@@ -1,152 +0,0 @@
|
||||
// gpu-config.js - Comprehensive GPU configuration manager
|
||||
const { app } = require('electron');
|
||||
|
||||
function envTruthy(value) {
|
||||
if (value === undefined || value === null) return false;
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on';
|
||||
}
|
||||
|
||||
class GPUConfig {
|
||||
constructor() {
|
||||
this.isGPUSupported = false;
|
||||
this.fallbackApplied = false;
|
||||
}
|
||||
|
||||
// Apply GPU configuration based on system capabilities
|
||||
configure() {
|
||||
console.log('Configuring GPU settings...');
|
||||
|
||||
// Try to detect if we're on a system that supports GPU acceleration
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
|
||||
console.log(`Platform: ${platform}, Architecture: ${arch}`);
|
||||
|
||||
// Start with conservative settings that usually work
|
||||
this.applyConservativeSettings();
|
||||
|
||||
if (platform === 'linux') {
|
||||
const env = process.env;
|
||||
const profile = String(env.NEBULA_GPU_PROFILE || '').toLowerCase();
|
||||
const forcedSoftware = envTruthy(env.NEBULA_GPU_FORCE_SOFTWARE) || profile === 'software';
|
||||
const optInRequested = envTruthy(env.NEBULA_GPU_TWEAKS) || envTruthy(env.NEBULA_GPU_ALLOW_LINUX) || envTruthy(env.NEBULA_GPU_FORCE_GPU) || (profile && profile !== 'software') || Boolean(env.NEBULA_GPU_GL) || Boolean(env.NEBULA_GPU_EXTRA_ARGS);
|
||||
|
||||
if (forcedSoftware || !optInRequested) {
|
||||
console.log('Linux detected: Disabling GPU (no opt-in overrides present) and enforcing no-sandbox');
|
||||
app.commandLine.appendSwitch('disable-gpu');
|
||||
app.commandLine.appendSwitch('no-sandbox');
|
||||
this.fallbackApplied = true;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Linux GPU opt-in detected: leaving GPU acceleration enabled for this session');
|
||||
}
|
||||
|
||||
// Try to enable GPU features progressively
|
||||
this.tryEnableGPU();
|
||||
}
|
||||
|
||||
applyConservativeSettings() {
|
||||
// Essential switches that usually don't cause issues
|
||||
app.commandLine.appendSwitch('no-sandbox');
|
||||
app.commandLine.appendSwitch('disable-dev-shm-usage');
|
||||
app.commandLine.appendSwitch('disable-gpu-sandbox');
|
||||
|
||||
// Performance improvements that don't rely on GPU
|
||||
app.commandLine.appendSwitch('disable-background-timer-throttling');
|
||||
app.commandLine.appendSwitch('disable-renderer-backgrounding');
|
||||
app.commandLine.appendSwitch('disable-backgrounding-occluded-windows');
|
||||
app.commandLine.appendSwitch('enable-quic');
|
||||
app.commandLine.appendSwitch('max_old_space_size', '4096');
|
||||
}
|
||||
|
||||
tryEnableGPU() {
|
||||
try {
|
||||
// GPU acceleration switches
|
||||
app.commandLine.appendSwitch('ignore-gpu-blacklist');
|
||||
app.commandLine.appendSwitch('ignore-gpu-blocklist');
|
||||
|
||||
// On Linux/SteamOS, these aggressive flags can cause webview rendering issues (black screen)
|
||||
// We disable them for Linux to ensure stability
|
||||
if (process.platform !== 'linux') {
|
||||
app.commandLine.appendSwitch('enable-gpu-rasterization');
|
||||
app.commandLine.appendSwitch('enable-zero-copy');
|
||||
}
|
||||
|
||||
// Video acceleration (usually safer than full GPU)
|
||||
app.commandLine.appendSwitch('enable-accelerated-video-decode');
|
||||
app.commandLine.appendSwitch('enable-accelerated-mjpeg-decode');
|
||||
|
||||
// Conservative feature enabling
|
||||
app.commandLine.appendSwitch('enable-features', 'VaapiVideoDecoder');
|
||||
|
||||
console.log('GPU acceleration switches applied');
|
||||
} catch (err) {
|
||||
console.error('Error applying GPU switches:', err);
|
||||
this.applyFallback();
|
||||
}
|
||||
}
|
||||
|
||||
applyFallback() {
|
||||
console.log('Applying GPU fallback configuration...');
|
||||
|
||||
// Force software rendering if GPU fails
|
||||
app.commandLine.appendSwitch('disable-gpu');
|
||||
app.commandLine.appendSwitch('disable-gpu-compositing');
|
||||
app.commandLine.appendSwitch('disable-software-rasterizer');
|
||||
|
||||
this.fallbackApplied = true;
|
||||
this.isGPUSupported = false;
|
||||
}
|
||||
|
||||
// Check if GPU is working after app starts
|
||||
async checkGPUStatus() {
|
||||
try {
|
||||
const gpuInfo = app.getGPUFeatureStatus();
|
||||
|
||||
// Check if any critical GPU features are enabled
|
||||
const enabledFeatures = Object.entries(gpuInfo)
|
||||
.filter(([key, value]) => !value.includes('disabled'))
|
||||
.map(([key]) => key);
|
||||
|
||||
this.isGPUSupported = enabledFeatures.length > 2; // At least some features working
|
||||
|
||||
console.log('GPU Status Check:');
|
||||
console.log('- Enabled features:', enabledFeatures);
|
||||
console.log('- GPU supported:', this.isGPUSupported);
|
||||
|
||||
return {
|
||||
isSupported: this.isGPUSupported,
|
||||
enabledFeatures,
|
||||
fullStatus: gpuInfo
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('GPU status check failed:', err);
|
||||
return { isSupported: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
getRecommendations() {
|
||||
const recommendations = [];
|
||||
|
||||
if (!this.isGPUSupported) {
|
||||
recommendations.push('GPU acceleration is not available on this system');
|
||||
recommendations.push('The browser will use software rendering (slower but stable)');
|
||||
recommendations.push('Consider updating your graphics drivers');
|
||||
recommendations.push('Check if your system supports hardware acceleration');
|
||||
} else {
|
||||
recommendations.push('GPU acceleration is working');
|
||||
recommendations.push('Browser should have good performance');
|
||||
}
|
||||
|
||||
if (this.fallbackApplied) {
|
||||
recommendations.push('Fallback mode is active due to GPU issues');
|
||||
recommendations.push('Performance may be reduced but stability improved');
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GPUConfig;
|
||||
@@ -1,105 +0,0 @@
|
||||
// gpu-fallback.js - GPU error handling and fallback system
|
||||
const { app } = require('electron');
|
||||
|
||||
class GPUFallback {
|
||||
constructor() {
|
||||
this.gpuEnabled = true;
|
||||
this.fallbackLevel = 0;
|
||||
this.maxFallbacks = 3;
|
||||
}
|
||||
|
||||
// Apply progressive GPU fallbacks
|
||||
applyFallback(level = 0) {
|
||||
console.log(`Applying GPU fallback level ${level}`);
|
||||
|
||||
switch (level) {
|
||||
case 0:
|
||||
// Level 0: Conservative GPU settings (already applied in main.js)
|
||||
break;
|
||||
|
||||
case 1:
|
||||
// Level 1: Disable hardware acceleration for some features
|
||||
app.commandLine.appendSwitch('disable-accelerated-2d-canvas');
|
||||
app.commandLine.appendSwitch('disable-accelerated-jpeg-decoding');
|
||||
break;
|
||||
|
||||
case 2:
|
||||
// Level 2: Software rendering only
|
||||
app.commandLine.appendSwitch('disable-gpu');
|
||||
app.commandLine.appendSwitch('disable-gpu-compositing');
|
||||
this.gpuEnabled = false;
|
||||
break;
|
||||
|
||||
case 3:
|
||||
// Level 3: Most conservative settings
|
||||
app.commandLine.appendSwitch('disable-gpu');
|
||||
app.commandLine.appendSwitch('disable-gpu-compositing');
|
||||
app.commandLine.appendSwitch('disable-software-rasterizer');
|
||||
app.commandLine.appendSwitch('disable-2d-canvas-image-chromium');
|
||||
this.gpuEnabled = false;
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('Maximum fallback level reached');
|
||||
}
|
||||
|
||||
this.fallbackLevel = level;
|
||||
}
|
||||
|
||||
// Check if GPU is working properly
|
||||
async checkGPUStatus() {
|
||||
try {
|
||||
const gpuInfo = app.getGPUFeatureStatus();
|
||||
|
||||
// Check for critical GPU failures
|
||||
const criticalFeatures = ['gpu_compositing', 'webgl', 'webgl2'];
|
||||
const failures = criticalFeatures.filter(feature =>
|
||||
gpuInfo[feature] && gpuInfo[feature].includes('disabled')
|
||||
);
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.warn('GPU features disabled:', failures);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('GPU status check failed:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle GPU process crashes
|
||||
setupCrashHandling() {
|
||||
let crashCount = 0;
|
||||
|
||||
app.on('gpu-process-crashed', (event, killed) => {
|
||||
crashCount++;
|
||||
console.error(`GPU process crashed (count: ${crashCount}), killed: ${killed}`);
|
||||
|
||||
if (crashCount >= 3 && this.fallbackLevel < this.maxFallbacks) {
|
||||
console.log('Too many GPU crashes, applying fallback...');
|
||||
this.applyFallback(this.fallbackLevel + 1);
|
||||
|
||||
// Restart the app if needed
|
||||
if (!killed) {
|
||||
setTimeout(() => {
|
||||
app.relaunch();
|
||||
app.exit();
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Get current GPU status
|
||||
getStatus() {
|
||||
return {
|
||||
gpuEnabled: this.gpuEnabled,
|
||||
fallbackLevel: this.fallbackLevel,
|
||||
isHardwareAccelerated: this.fallbackLevel < 2
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = GPUFallback;
|
||||
@@ -1,103 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Assemble nebula-appdir from extracted squashfs-root
|
||||
set -euo pipefail
|
||||
SRC="${1:-squashfs-root}"
|
||||
DEST="${2:-nebula-appdir}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
if [ ! -d "$SRC" ]; then
|
||||
echo "Source $SRC not found. Extract the AppImage first (./dist/Nebula-*.AppImage --appimage-extract)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Copy extracted contents into DEST
|
||||
mkdir -p "$DEST"
|
||||
cp -a "$SRC/." "$DEST/"
|
||||
|
||||
# Ensure launcher/binary exist (prefer extracted run-nebula.sh as fallback)
|
||||
if [ -f "$DEST/run-nebula.sh" ] && [ ! -f "$DEST/Nebula" ]; then
|
||||
mv "$DEST/run-nebula.sh" "$DEST/Nebula" 2>/dev/null || true
|
||||
fi
|
||||
chmod +x "$DEST/Nebula" || true
|
||||
|
||||
# Ensure directories for icons and desktop entries
|
||||
mkdir -p "$DEST/usr/share/icons/hicolor/256x256/apps"
|
||||
mkdir -p "$DEST/usr/share/applications"
|
||||
|
||||
# Copy icon if present at top level of extracted AppImage
|
||||
if [ -f "$SRC/nebula.png" ]; then
|
||||
cp "$SRC/nebula.png" "$DEST/usr/share/icons/hicolor/256x256/apps/nebula.png"
|
||||
fi
|
||||
|
||||
# Also embed project icon if present in repo assets
|
||||
PROJECT_ICON="$(cd "$(dirname "$0")" && pwd)/assets/images/Logos/Nebula-Favicon.png"
|
||||
if [ -f "$PROJECT_ICON" ]; then
|
||||
echo "Embedding project icon into AppDir: $PROJECT_ICON"
|
||||
cp "$PROJECT_ICON" "$DEST/usr/share/icons/hicolor/256x256/apps/nebula.png"
|
||||
fi
|
||||
|
||||
# Install desktop file into AppDir
|
||||
if [ -f "$DEST/nebula.desktop" ]; then
|
||||
cp "$DEST/nebula.desktop" "$DEST/usr/share/applications/nebula.desktop"
|
||||
else
|
||||
cat > "$DEST/usr/share/applications/nebula.desktop" <<'EOF'
|
||||
[Desktop Entry]
|
||||
Name=Nebula
|
||||
Comment=Nebula Browser
|
||||
Exec=./Nebula %U
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Icon=nebula
|
||||
Categories=Network;WebBrowser;
|
||||
StartupWMClass=Nebula
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Match appdir-example layout: extract app.asar to resources/app and keep app.asar.orig
|
||||
if [ -f "$DEST/resources/app.asar" ]; then
|
||||
if command -v npx &> /dev/null; then
|
||||
echo "Extracting app.asar to resources/app (keeping app.asar.orig)"
|
||||
(cd "$DEST/resources" && npx asar extract "app.asar" "app")
|
||||
mv "$DEST/resources/app.asar" "$DEST/resources/app.asar.orig" 2>/dev/null || true
|
||||
else
|
||||
echo "Warning: npx not found; leaving app.asar in place."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Copy Linux launch wrappers if present in appdir-example
|
||||
if [ -f "$SCRIPT_DIR/appdir-example/run-nebula.sh" ]; then
|
||||
cp "$SCRIPT_DIR/appdir-example/run-nebula.sh" "$DEST/run-nebula.sh"
|
||||
sed -i.bak 's|usr/data|usr/user-data|g' "$DEST/run-nebula.sh" && rm -f "$DEST/run-nebula.sh.bak"
|
||||
chmod +x "$DEST/run-nebula.sh" || true
|
||||
fi
|
||||
if [ -f "$SCRIPT_DIR/appdir-example/steam_appid.txt" ]; then
|
||||
cp "$SCRIPT_DIR/appdir-example/steam_appid.txt" "$DEST/steam_appid.txt"
|
||||
fi
|
||||
if [ -f "$SCRIPT_DIR/appdir-example/nebula.desktop" ]; then
|
||||
cp "$SCRIPT_DIR/appdir-example/nebula.desktop" "$DEST/nebula.desktop"
|
||||
cp "$SCRIPT_DIR/appdir-example/nebula.desktop" "$DEST/usr/share/applications/nebula.desktop"
|
||||
fi
|
||||
# Ensure root launchers exist (from example if needed)
|
||||
if [ -f "$SCRIPT_DIR/appdir-example/Nebula-Desktop" ]; then
|
||||
cp "$SCRIPT_DIR/appdir-example/Nebula-Desktop" "$DEST/Nebula-Desktop"
|
||||
chmod +x "$DEST/Nebula-Desktop" || true
|
||||
fi
|
||||
if [ -f "$SCRIPT_DIR/appdir-example/Nebula-Controller" ]; then
|
||||
cp "$SCRIPT_DIR/appdir-example/Nebula-Controller" "$DEST/Nebula-Controller"
|
||||
chmod +x "$DEST/Nebula-Controller" || true
|
||||
fi
|
||||
# Fallback: create Nebula as symlink to Nebula-Desktop
|
||||
if [ ! -f "$DEST/Nebula" ] && [ -f "$DEST/Nebula-Desktop" ]; then
|
||||
ln -sf "Nebula-Desktop" "$DEST/Nebula"
|
||||
fi
|
||||
|
||||
# Fix permissions
|
||||
chmod -R a+r "$DEST/usr/share/icons/hicolor/256x256/apps" || true
|
||||
chmod +x "$DEST/Nebula" "$DEST/Nebula-Desktop" "$DEST/Nebula-Controller" || true
|
||||
mkdir -p "$DEST/usr/user-data"
|
||||
chmod 700 "$DEST/usr/user-data" || true
|
||||
|
||||
echo "AppDir assembled at $DEST."
|
||||
echo " Desktop mode: $DEST/Nebula-Desktop"
|
||||
echo " Controller mode: $DEST/Nebula-Controller"
|
||||
echo " Default: $DEST/Nebula (symlink to Nebula-Desktop)"
|
||||
@@ -1,67 +0,0 @@
|
||||
{
|
||||
"name": "nebula",
|
||||
"productName": "Nebula",
|
||||
"version": "1.3.2",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"start:dev": "electron . --no-sandbox --disable-gpu",
|
||||
"start:linux": "electron . --no-sandbox",
|
||||
"dist": "electron-builder",
|
||||
"run": "electron ."
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"dompurify": "^3.1.6",
|
||||
"electron-updater": "^6.6.2",
|
||||
"highlight.js": "^11.9.0",
|
||||
"marked": "^12.0.2",
|
||||
"steamworks.js": "^0.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^40.0.0",
|
||||
"electron-builder": "^23.0.0",
|
||||
"electron-nightly": "^39.0.0-nightly.20250811"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.andrewzambazos.nebula",
|
||||
"protocols": [
|
||||
{
|
||||
"name": "Nebula",
|
||||
"schemes": [
|
||||
"http",
|
||||
"https"
|
||||
]
|
||||
}
|
||||
],
|
||||
"publish": [
|
||||
{
|
||||
"provider": "github",
|
||||
"owner": "Bobbybear007",
|
||||
"repo": "NebulaBrowser"
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
"**/*",
|
||||
"!user-data/**",
|
||||
"!*.backup.json"
|
||||
],
|
||||
"extraResources": [],
|
||||
"mac": {
|
||||
"category": "public.app-category.productivity",
|
||||
"icon": "assets/images/Logos/Nebula-Favicon.icns"
|
||||
},
|
||||
"win": {
|
||||
"icon": "assets/images/Logos/Nebula-Favicon.ico"
|
||||
},
|
||||
"linux": {
|
||||
"icon": "assets/images/Logos/Nebula-Favicon.png",
|
||||
"target": [
|
||||
"AppImage"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
// performance-monitor.js - Monitor and optimize browser performance
|
||||
const { app } = require('electron');
|
||||
|
||||
class PerformanceMonitor {
|
||||
constructor() {
|
||||
this.metrics = {
|
||||
memoryUsage: [],
|
||||
cpuUsage: [],
|
||||
loadTimes: []
|
||||
};
|
||||
this.startTime = Date.now();
|
||||
}
|
||||
|
||||
// Monitor memory usage
|
||||
trackMemory() {
|
||||
const usage = process.memoryUsage();
|
||||
this.metrics.memoryUsage.push({
|
||||
timestamp: Date.now(),
|
||||
rss: usage.rss,
|
||||
heapUsed: usage.heapUsed,
|
||||
heapTotal: usage.heapTotal,
|
||||
external: usage.external
|
||||
});
|
||||
|
||||
// Keep only last 100 measurements
|
||||
if (this.metrics.memoryUsage.length > 100) {
|
||||
this.metrics.memoryUsage.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// Monitor CPU usage
|
||||
trackCPU() {
|
||||
const usage = process.cpuUsage();
|
||||
this.metrics.cpuUsage.push({
|
||||
timestamp: Date.now(),
|
||||
user: usage.user,
|
||||
system: usage.system
|
||||
});
|
||||
|
||||
if (this.metrics.cpuUsage.length > 100) {
|
||||
this.metrics.cpuUsage.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// Track page load times
|
||||
trackLoadTime(url, loadTime) {
|
||||
this.metrics.loadTimes.push({
|
||||
timestamp: Date.now(),
|
||||
url,
|
||||
loadTime
|
||||
});
|
||||
|
||||
if (this.metrics.loadTimes.length > 50) {
|
||||
this.metrics.loadTimes.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// Get performance report
|
||||
getReport() {
|
||||
const memAvg = this.metrics.memoryUsage.length > 0
|
||||
? this.metrics.memoryUsage.reduce((sum, m) => sum + m.heapUsed, 0) / this.metrics.memoryUsage.length
|
||||
: 0;
|
||||
|
||||
const avgLoadTime = this.metrics.loadTimes.length > 0
|
||||
? this.metrics.loadTimes.reduce((sum, l) => sum + l.loadTime, 0) / this.metrics.loadTimes.length
|
||||
: 0;
|
||||
|
||||
return {
|
||||
uptime: Date.now() - this.startTime,
|
||||
averageMemoryUsage: Math.round(memAvg / 1024 / 1024), // MB
|
||||
averageLoadTime: Math.round(avgLoadTime),
|
||||
totalPageLoads: this.metrics.loadTimes.length
|
||||
};
|
||||
}
|
||||
|
||||
// Start monitoring
|
||||
start() {
|
||||
// Monitor every 30 seconds
|
||||
setInterval(() => {
|
||||
this.trackMemory();
|
||||
this.trackCPU();
|
||||
}, 30000);
|
||||
|
||||
// Log performance report every 5 minutes
|
||||
setInterval(() => {
|
||||
const report = this.getReport();
|
||||
console.log('Performance Report:', report);
|
||||
}, 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
// Force garbage collection if available
|
||||
forceGC() {
|
||||
if (global.gc) {
|
||||
global.gc();
|
||||
console.log('Forced garbage collection');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PerformanceMonitor;
|
||||
@@ -1,285 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { pathToFileURL } = require('url');
|
||||
const { app, session, Menu, ipcMain, BrowserWindow, dialog, shell } = require('electron');
|
||||
|
||||
class PluginManager {
|
||||
constructor() {
|
||||
this.plugins = []; // { id, dir, manifest, mod, enabled }
|
||||
this.rendererPreloads = []; // absolute file paths
|
||||
this.rendererPages = []; // { id, file, pluginId }
|
||||
this._listeners = {
|
||||
'app-ready': [],
|
||||
'window-created': [],
|
||||
'web-contents-created': [],
|
||||
'session-configured': [],
|
||||
};
|
||||
this._webRequestHandlers = []; // { filter, listener }
|
||||
this._contextMenuContribs = []; // [function(template, params, sender)]
|
||||
}
|
||||
|
||||
getPluginDirs() {
|
||||
const appDir = path.join(app.getAppPath(), 'plugins');
|
||||
const userDir = path.join(app.getPath('userData'), 'plugins');
|
||||
return [appDir, userDir];
|
||||
}
|
||||
|
||||
ensureUserPluginsDir() {
|
||||
try {
|
||||
const userDir = path.join(app.getPath('userData'), 'plugins');
|
||||
fs.mkdirSync(userDir, { recursive: true });
|
||||
return userDir;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
loadAll() {
|
||||
this.plugins = [];
|
||||
this.rendererPreloads = [];
|
||||
this.rendererPages = [];
|
||||
const dirs = this.getPluginDirs();
|
||||
for (const root of dirs) {
|
||||
let entries = [];
|
||||
try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { continue; }
|
||||
for (const ent of entries) {
|
||||
if (!ent.isDirectory()) continue;
|
||||
const dir = path.join(root, ent.name);
|
||||
const manifestPath = path.join(dir, 'plugin.json');
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
// Normalize optional fields
|
||||
const cats = manifest.categories;
|
||||
if (typeof cats === 'string') manifest.categories = [cats];
|
||||
else if (Array.isArray(cats)) manifest.categories = cats.filter(x => typeof x === 'string');
|
||||
else if (cats == null) manifest.categories = [];
|
||||
|
||||
const au = manifest.authors;
|
||||
if (typeof au === 'string') manifest.authors = [au];
|
||||
else if (Array.isArray(au)) manifest.authors = au.filter(x => (typeof x === 'string') || (x && typeof x === 'object' && typeof x.name === 'string'));
|
||||
else if (au == null) manifest.authors = [];
|
||||
} catch { continue; }
|
||||
const enabled = manifest.enabled !== false; // default true
|
||||
const id = manifest.id || ent.name;
|
||||
const record = { id, dir, manifest, enabled, mod: null, mainPath: null };
|
||||
if (enabled) {
|
||||
// Load main module if provided
|
||||
if (manifest.main) {
|
||||
const mainPath = path.join(dir, manifest.main);
|
||||
try {
|
||||
// eslint-disable-next-line import/no-dynamic-require, global-require
|
||||
record.mod = require(mainPath);
|
||||
record.mainPath = mainPath;
|
||||
} catch (e) {
|
||||
console.error(`[Plugins] Failed to load main for ${id}:`, e);
|
||||
}
|
||||
}
|
||||
// Collect renderer preload if provided
|
||||
if (manifest.rendererPreload) {
|
||||
const rp = path.join(dir, manifest.rendererPreload);
|
||||
try {
|
||||
if (fs.existsSync(rp)) this.rendererPreloads.push(rp);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
this.plugins.push(record);
|
||||
}
|
||||
}
|
||||
// Activate plugins with activate(ctx)
|
||||
for (const p of this.plugins) {
|
||||
if (!p.enabled || !p.mod) continue;
|
||||
try {
|
||||
const ctx = this._buildContext(p);
|
||||
if (typeof p.mod.activate === 'function') {
|
||||
p.mod.activate(ctx);
|
||||
} else if (typeof p.mod === 'function') {
|
||||
// support default export as function(ctx)
|
||||
p.mod(ctx);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[Plugins] Error activating ${p.id}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_buildContext(plugin) {
|
||||
const manager = this;
|
||||
const logPrefix = `[Plugin:${plugin.id}]`;
|
||||
return {
|
||||
app,
|
||||
BrowserWindow,
|
||||
ipcMain,
|
||||
session,
|
||||
Menu,
|
||||
dialog,
|
||||
shell,
|
||||
paths: {
|
||||
appPath: app.getAppPath(),
|
||||
userData: app.getPath('userData'),
|
||||
pluginDir: plugin.dir,
|
||||
},
|
||||
log: (...args) => console.log(logPrefix, ...args),
|
||||
warn: (...args) => console.warn(logPrefix, ...args),
|
||||
error: (...args) => console.error(logPrefix, ...args),
|
||||
on: (evt, cb) => manager.on(evt, cb),
|
||||
registerIPC: (channel, handler) => {
|
||||
try { ipcMain.handle(channel, handler); } catch (e) { console.error(logPrefix, 'registerIPC failed', e); }
|
||||
},
|
||||
registerWebRequest: (filter, listener) => {
|
||||
try { manager._webRequestHandlers.push({ filter, listener }); } catch (e) { console.error(logPrefix, 'registerWebRequest failed', e); }
|
||||
},
|
||||
contributeContextMenu: (contribFn) => {
|
||||
try { manager._contextMenuContribs.push(contribFn); } catch (e) { console.error(logPrefix, 'contributeContextMenu failed', e); }
|
||||
},
|
||||
// Register a dedicated internal page (shown via nebula://<id>)
|
||||
registerRendererPage: ({ id, html }) => {
|
||||
try {
|
||||
if (!id || !html) return;
|
||||
let fileUrl = null;
|
||||
try { fileUrl = pathToFileURL(html).href; } catch {}
|
||||
manager.rendererPages.push({ id, file: html, fileUrl, pluginId: plugin.id });
|
||||
console.log('[Plugins] Registered page:', id, '->', html, 'fileUrl:', fileUrl);
|
||||
manager.log('registered page:', id, '->', html);
|
||||
} catch (e) { manager.error('registerRendererPage failed', e); }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
getRendererPreloads() {
|
||||
return Array.from(new Set(this.rendererPreloads));
|
||||
}
|
||||
|
||||
getRendererPages() {
|
||||
// Return a shallow copy so callers can't mutate internal array
|
||||
return this.rendererPages.map(p => ({ ...p }));
|
||||
}
|
||||
|
||||
on(evt, cb) {
|
||||
if (!this._listeners[evt]) this._listeners[evt] = [];
|
||||
this._listeners[evt].push(cb);
|
||||
}
|
||||
|
||||
emit(evt, ...args) {
|
||||
const list = this._listeners[evt] || [];
|
||||
for (const cb of list) {
|
||||
try { cb(...args); } catch (e) { console.error('[Plugins] listener error for', evt, e); }
|
||||
}
|
||||
}
|
||||
|
||||
applyWebRequestHandlers(ses) {
|
||||
try {
|
||||
if (!ses || !ses.webRequest) return;
|
||||
for (const { filter, listener } of this._webRequestHandlers) {
|
||||
try {
|
||||
ses.webRequest.onBeforeRequest(filter || {}, (details, callback) => {
|
||||
try {
|
||||
const res = listener(details);
|
||||
if (res && typeof res === 'object') callback(res); else callback({ cancel: false });
|
||||
} catch (e) {
|
||||
console.error('[Plugins] webRequest handler error:', e);
|
||||
callback({ cancel: false });
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[Plugins] Failed to attach webRequest handler:', e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Plugins] applyWebRequestHandlers error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
applyContextMenuContrib(template, params, sender) {
|
||||
try {
|
||||
for (const fn of this._contextMenuContribs) {
|
||||
try { fn(template, params, sender); } catch (e) { console.error('[Plugins] context menu contrib error:', e); }
|
||||
}
|
||||
} catch (e) { console.error('[Plugins] applyContextMenuContrib error:', e); }
|
||||
}
|
||||
|
||||
getPluginsInfo() {
|
||||
return this.plugins.map(p => ({
|
||||
id: p.id,
|
||||
name: p.manifest.name || p.id,
|
||||
version: p.manifest.version || '0.0.0',
|
||||
description: p.manifest.description || '',
|
||||
categories: Array.isArray(p.manifest.categories) ? p.manifest.categories : [],
|
||||
authors: Array.isArray(p.manifest.authors)
|
||||
? p.manifest.authors.map(x => (typeof x === 'string' ? x : (x && x.name) || '')).filter(Boolean)
|
||||
: [],
|
||||
enabled: !!p.enabled,
|
||||
hasMain: !!p.manifest.main,
|
||||
hasRendererPreload: !!p.manifest.rendererPreload,
|
||||
dir: p.dir
|
||||
}));
|
||||
}
|
||||
|
||||
// Fast discovery that does not activate plugins; always shows disabled items
|
||||
discoverPlugins() {
|
||||
const out = [];
|
||||
for (const root of this.getPluginDirs()) {
|
||||
let entries = [];
|
||||
try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { continue; }
|
||||
for (const ent of entries) {
|
||||
if (!ent.isDirectory()) continue;
|
||||
const dir = path.join(root, ent.name);
|
||||
const manifestPath = path.join(dir, 'plugin.json');
|
||||
try {
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
const cats = manifest.categories;
|
||||
const categories = typeof cats === 'string' ? [cats] : Array.isArray(cats) ? cats.filter(x => typeof x === 'string') : [];
|
||||
const au = manifest.authors;
|
||||
const authors = typeof au === 'string'
|
||||
? [au]
|
||||
: Array.isArray(au)
|
||||
? au.map(x => (typeof x === 'string' ? x : (x && x.name) || null)).filter(Boolean)
|
||||
: [];
|
||||
out.push({
|
||||
id: manifest.id || ent.name,
|
||||
name: manifest.name || ent.name,
|
||||
version: manifest.version || '0.0.0',
|
||||
description: manifest.description || '',
|
||||
categories,
|
||||
authors,
|
||||
enabled: manifest.enabled !== false,
|
||||
hasMain: !!manifest.main,
|
||||
hasRendererPreload: !!manifest.rendererPreload,
|
||||
dir
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async setEnabled(id, enabled) {
|
||||
const p = this.plugins.find(x => x.id === id) || null;
|
||||
if (!p) throw new Error('Plugin not found: ' + id);
|
||||
const manifestPath = path.join(p.dir, 'plugin.json');
|
||||
let manifest;
|
||||
try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } catch (e) { throw new Error('Manifest read failed: ' + e.message); }
|
||||
manifest.enabled = !!enabled;
|
||||
await fs.promises.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
|
||||
return true;
|
||||
}
|
||||
|
||||
_clearRequireCache(p) {
|
||||
try {
|
||||
if (p && p.mainPath) {
|
||||
const k = require.resolve(p.mainPath);
|
||||
if (require.cache[k]) delete require.cache[k];
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
reload(id) {
|
||||
if (id) {
|
||||
const p = this.plugins.find(x => x.id === id);
|
||||
if (p) this._clearRequireCache(p);
|
||||
} else {
|
||||
for (const p of this.plugins) this._clearRequireCache(p);
|
||||
}
|
||||
this.loadAll();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PluginManager;
|
||||
@@ -1,381 +0,0 @@
|
||||
/**
|
||||
* Portable Data Manager for Nebula Browser
|
||||
*
|
||||
* Handles portable user data storage for all platforms (Windows, macOS, Linux).
|
||||
* Data is stored in a 'user-data' folder within the application directory,
|
||||
* keeping all user data local to the compiled project.
|
||||
*
|
||||
* Security considerations:
|
||||
* - Data is stored with restricted permissions (0700 for directories, 0600 for files on Unix)
|
||||
* - Path validation prevents directory traversal attacks
|
||||
* - Portable mode is enabled by default on all platforms
|
||||
* - Can be disabled via NEBULA_PORTABLE=0 environment variable
|
||||
*/
|
||||
|
||||
const { app } = require('electron');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
class PortableDataManager {
|
||||
constructor() {
|
||||
this._isPortable = null;
|
||||
this._portableDataPath = null;
|
||||
this._initialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the application's root directory
|
||||
* Works for both development and packaged builds
|
||||
*/
|
||||
_getAppRootDir() {
|
||||
// In packaged app, process.resourcesPath points to resources folder
|
||||
// We want the parent directory (the app folder itself)
|
||||
if (app.isPackaged) {
|
||||
// For packaged apps:
|
||||
// - Windows: path\to\app\resources -> path\to\app
|
||||
// - macOS: path/to/App.app/Contents/Resources -> path/to/App.app/Contents
|
||||
// - Linux: path/to/app/resources -> path/to/app
|
||||
const resourcesPath = process.resourcesPath;
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
// On macOS, go up two levels from Resources to get to App.app parent folder
|
||||
// But we want to store data inside the .app bundle's Contents folder for portability
|
||||
return path.dirname(resourcesPath); // Contents folder
|
||||
} else {
|
||||
// Windows and Linux: go up one level from resources
|
||||
return path.dirname(resourcesPath);
|
||||
}
|
||||
} else {
|
||||
// Development mode: use __dirname (the directory containing main.js)
|
||||
return __dirname;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're running in portable mode
|
||||
* Portable mode is enabled by default on all platforms.
|
||||
*
|
||||
* Can be disabled by setting NEBULA_PORTABLE=0 or NEBULA_PORTABLE=false
|
||||
* Can specify custom path via NEBULA_PORTABLE_PATH environment variable
|
||||
*/
|
||||
isPortableMode() {
|
||||
if (this._isPortable !== null) {
|
||||
return this._isPortable;
|
||||
}
|
||||
|
||||
// Check if NEBULA_PORTABLE is explicitly set to disable
|
||||
const portableEnv = process.env.NEBULA_PORTABLE;
|
||||
if (portableEnv !== undefined) {
|
||||
const isDisabled = portableEnv === '0' ||
|
||||
portableEnv.toLowerCase() === 'false' ||
|
||||
portableEnv.toLowerCase() === 'no';
|
||||
|
||||
if (isDisabled) {
|
||||
this._isPortable = false;
|
||||
console.log('[Portable] Portable mode disabled via NEBULA_PORTABLE environment variable');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Portable mode is enabled by default on all platforms
|
||||
this._isPortable = true;
|
||||
return this._isPortable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the portable data directory path
|
||||
* Uses NEBULA_PORTABLE_PATH if set, otherwise creates app-local 'user-data'.
|
||||
* Linux AppDir builds prefer 'usr/user-data' to keep writable data inside AppDir.
|
||||
*/
|
||||
getPortableDataPath() {
|
||||
if (this._portableDataPath !== null) {
|
||||
return this._portableDataPath;
|
||||
}
|
||||
|
||||
if (!this.isPortableMode()) {
|
||||
this._portableDataPath = '';
|
||||
return '';
|
||||
}
|
||||
|
||||
// First, check if custom path is provided via environment variable
|
||||
const customPath = process.env.NEBULA_PORTABLE_PATH;
|
||||
if (customPath) {
|
||||
const resolvedPath = path.resolve(customPath);
|
||||
if (this._isPathSafe(resolvedPath)) {
|
||||
this._portableDataPath = resolvedPath;
|
||||
console.log(`[Portable] Using custom portable path: ${resolvedPath}`);
|
||||
return this._portableDataPath;
|
||||
} else {
|
||||
console.warn('[Portable] Custom path is unsafe, using default location');
|
||||
}
|
||||
}
|
||||
|
||||
// Default: app-local user data
|
||||
// - Windows: beside the executable
|
||||
// - macOS: inside .app Contents (portable bundle)
|
||||
// - Linux AppDir: <AppDir>/usr/user-data
|
||||
// - Other Linux/dev: beside the app root
|
||||
const appRoot = this._getAppRootDir();
|
||||
let dataPath = path.join(appRoot, 'user-data');
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
const appDirUsr = path.join(appRoot, 'usr');
|
||||
try {
|
||||
if (fs.existsSync(appDirUsr) && fs.statSync(appDirUsr).isDirectory()) {
|
||||
dataPath = path.join(appDirUsr, 'user-data');
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Portable] Could not inspect Linux AppDir usr path, using app root user-data');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the path
|
||||
if (this._isPathSafe(dataPath)) {
|
||||
this._portableDataPath = dataPath;
|
||||
console.log(`[Portable] Using portable data path: ${dataPath}`);
|
||||
} else {
|
||||
console.error('[Portable] Default path is unsafe, falling back to system default');
|
||||
this._portableDataPath = '';
|
||||
}
|
||||
|
||||
return this._portableDataPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize portable data directory with secure permissions
|
||||
* Must be called before app.ready event
|
||||
*/
|
||||
initialize() {
|
||||
if (this._initialized) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!this.isPortableMode()) {
|
||||
console.log('[Portable] Not in portable mode, using default paths');
|
||||
this._initialized = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
const dataPath = this.getPortableDataPath();
|
||||
if (!dataPath) {
|
||||
console.warn('[Portable] No valid portable path, using default paths');
|
||||
this._initialized = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create the data directory with secure permissions (owner only: rwx)
|
||||
this._ensureSecureDirectory(dataPath);
|
||||
|
||||
// Create subdirectories for organized storage
|
||||
// Note: Don't create 'Cache', 'Cookies', 'Network' - Electron manages these internally
|
||||
const subdirs = ['Local Storage', 'Session Storage', 'IndexedDB'];
|
||||
for (const subdir of subdirs) {
|
||||
this._ensureSecureDirectory(path.join(dataPath, subdir));
|
||||
}
|
||||
|
||||
// Set Electron's user data path to our portable location
|
||||
// This must be done BEFORE app.ready event
|
||||
app.setPath('userData', dataPath);
|
||||
app.setPath('sessionData', dataPath);
|
||||
|
||||
// Also redirect cache to be portable
|
||||
const cachePath = path.join(dataPath, 'Cache');
|
||||
app.setPath('cache', cachePath);
|
||||
|
||||
console.log(`[Portable] User data path set to: ${dataPath}`);
|
||||
console.log(`[Portable] Cache path set to: ${cachePath}`);
|
||||
|
||||
this._initialized = true;
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[Portable] Failed to initialize portable data:', err);
|
||||
this._initialized = true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path for a data file (bookmarks, history, etc.)
|
||||
* Returns portable path if in portable mode, otherwise returns __dirname path
|
||||
*/
|
||||
getDataFilePath(filename) {
|
||||
// Validate filename to prevent directory traversal
|
||||
if (!this._isFilenameSafe(filename)) {
|
||||
throw new Error(`Invalid filename: ${filename}`);
|
||||
}
|
||||
|
||||
if (this.isPortableMode()) {
|
||||
const portablePath = this.getPortableDataPath();
|
||||
if (portablePath) {
|
||||
return path.join(portablePath, filename);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to __dirname (project directory) for non-portable or if portable not configured
|
||||
// Note: In production, you might want to use app.getPath('userData') as fallback
|
||||
return null; // Return null to indicate caller should use their default path
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a directory exists with secure permissions
|
||||
* On Unix systems (macOS, Linux), applies restricted permissions (0700)
|
||||
* On Windows, creates directory with default permissions (ACLs handle security)
|
||||
*/
|
||||
_ensureSecureDirectory(dirPath) {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
if (process.platform === 'win32') {
|
||||
// Windows: create directory with default permissions
|
||||
// Windows ACLs handle security through inheritance
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
} else {
|
||||
// Unix (macOS, Linux): create with restricted permissions (owner only: rwx------)
|
||||
fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
console.log(`[Portable] Created secure directory: ${dirPath}`);
|
||||
} else {
|
||||
// Verify and fix permissions on existing directory (Unix only)
|
||||
if (process.platform !== 'win32') {
|
||||
try {
|
||||
const stats = fs.statSync(dirPath);
|
||||
if (stats.isDirectory()) {
|
||||
// Set secure permissions
|
||||
fs.chmodSync(dirPath, 0o700);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[Portable] Could not verify permissions for ${dirPath}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a file with secure permissions
|
||||
* On Unix systems, applies restricted permissions (0600)
|
||||
* On Windows, writes with default permissions
|
||||
*/
|
||||
writeSecureFile(filePath, data) {
|
||||
// Ensure parent directory exists with secure permissions
|
||||
const dir = path.dirname(filePath);
|
||||
this._ensureSecureDirectory(dir);
|
||||
|
||||
// Write file
|
||||
if (process.platform === 'win32') {
|
||||
fs.writeFileSync(filePath, data);
|
||||
} else {
|
||||
fs.writeFileSync(filePath, data, { mode: 0o600 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Async version of secure file write
|
||||
* On Unix systems, applies restricted permissions (0600)
|
||||
* On Windows, writes with default permissions
|
||||
*/
|
||||
async writeSecureFileAsync(filePath, data) {
|
||||
// Ensure parent directory exists with secure permissions
|
||||
const dir = path.dirname(filePath);
|
||||
this._ensureSecureDirectory(dir);
|
||||
|
||||
// Write file with restricted permissions (owner only: rw------- on Unix)
|
||||
if (process.platform === 'win32') {
|
||||
await fs.promises.writeFile(filePath, data);
|
||||
} else {
|
||||
await fs.promises.writeFile(filePath, data, { mode: 0o600 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate path safety (prevent directory traversal)
|
||||
* Works across Windows, macOS, and Linux
|
||||
*/
|
||||
_isPathSafe(testPath) {
|
||||
// Resolve to absolute path
|
||||
const resolved = path.resolve(testPath);
|
||||
|
||||
// Check for directory traversal patterns
|
||||
if (resolved.includes('..')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Platform-specific system path checks
|
||||
if (process.platform === 'win32') {
|
||||
// Windows: block system directories
|
||||
const dangerousWin = [
|
||||
'C:\\Windows',
|
||||
'C:\\Program Files',
|
||||
'C:\\Program Files (x86)',
|
||||
'C:\\ProgramData'
|
||||
];
|
||||
const resolvedLower = resolved.toLowerCase();
|
||||
for (const pattern of dangerousWin) {
|
||||
if (resolvedLower.startsWith(pattern.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else if (process.platform === 'darwin') {
|
||||
// macOS: block system directories
|
||||
const dangerousMac = ['/System', '/Library', '/usr', '/bin', '/sbin', '/etc', '/var'];
|
||||
for (const pattern of dangerousMac) {
|
||||
if (resolved.startsWith(pattern) && !resolved.includes('.app')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Linux: block system directories
|
||||
const dangerousLinux = ['~root', '/etc', '/var/run', '/proc', '/sys', '/dev'];
|
||||
for (const pattern of dangerousLinux) {
|
||||
if (resolved.includes(pattern)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const systemPaths = ['/bin', '/sbin', '/usr/bin', '/usr/sbin', '/boot', '/lib', '/lib64'];
|
||||
for (const sysPath of systemPaths) {
|
||||
if (resolved.startsWith(sysPath)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate filename safety
|
||||
*/
|
||||
_isFilenameSafe(filename) {
|
||||
// Check for directory traversal
|
||||
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for hidden files that might be system files
|
||||
if (filename.startsWith('.') && !filename.endsWith('.json')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status information for debugging
|
||||
*/
|
||||
getStatus() {
|
||||
return {
|
||||
isPortable: this.isPortableMode(),
|
||||
portablePath: this.getPortableDataPath(),
|
||||
appRootDir: this._getAppRootDir(),
|
||||
initialized: this._initialized,
|
||||
platform: process.platform,
|
||||
isPackaged: app.isPackaged,
|
||||
envPortable: process.env.NEBULA_PORTABLE,
|
||||
envPath: process.env.NEBULA_PORTABLE_PATH
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
const portableDataManager = new PortableDataManager();
|
||||
module.exports = portableDataManager;
|
||||
@@ -1,549 +0,0 @@
|
||||
// preload.js - Optimized version
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
let pathModule;
|
||||
let fsModule;
|
||||
try {
|
||||
pathModule = require('path');
|
||||
fsModule = require('fs');
|
||||
} catch (err) {
|
||||
pathModule = null;
|
||||
fsModule = null;
|
||||
}
|
||||
|
||||
// BrowserView tab id (desktop mode) injected via additionalArguments
|
||||
let nebulaTabId = null;
|
||||
try {
|
||||
const arg = (process?.argv || []).find(a => typeof a === 'string' && a.startsWith('--nebula-tab-id='));
|
||||
if (arg) nebulaTabId = arg.split('=')[1] || null;
|
||||
} catch {}
|
||||
|
||||
// =============================================================================
|
||||
// GAMEPAD HANDLER - Steam Deck / SteamOS Support
|
||||
// =============================================================================
|
||||
// This is CRITICAL for Steam Deck Game Mode: Steam only stops applying
|
||||
// Desktop mouse emulation when the app actively reads controller input.
|
||||
// By continuously polling navigator.getGamepads(), Steam recognizes that
|
||||
// the app is consuming gamepad events and backs off the mouse emulation layer.
|
||||
// =============================================================================
|
||||
|
||||
const gamepadState = {
|
||||
initialized: false,
|
||||
gamepads: {},
|
||||
connectedCount: 0,
|
||||
activeGamepadIndex: null,
|
||||
rafId: null,
|
||||
buttonStates: {},
|
||||
listeners: { connect: [], disconnect: [], button: [], axis: [], input: [] },
|
||||
};
|
||||
|
||||
const GAMEPAD_CONFIG = {
|
||||
STICK_DEADZONE: 0.15,
|
||||
DEBUG: false,
|
||||
};
|
||||
|
||||
function gamepadLog(...args) {
|
||||
if (GAMEPAD_CONFIG.DEBUG) {
|
||||
console.log('[NebulaGamepad]', ...args);
|
||||
}
|
||||
}
|
||||
|
||||
function initGamepadHandler() {
|
||||
if (gamepadState.initialized) return;
|
||||
|
||||
if (typeof navigator === 'undefined' || !navigator.getGamepads) {
|
||||
console.warn('[NebulaGamepad] Gamepad API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
gamepadLog('Initializing gamepad handler');
|
||||
|
||||
window.addEventListener('gamepadconnected', handleGamepadConnected);
|
||||
window.addEventListener('gamepaddisconnected', handleGamepadDisconnected);
|
||||
|
||||
// Initial scan for already-connected gamepads
|
||||
scanGamepads();
|
||||
|
||||
// Start polling loop - this is what tells Steam we're consuming gamepad input
|
||||
startGamepadPolling();
|
||||
|
||||
gamepadState.initialized = true;
|
||||
console.log('[NebulaGamepad] Gamepad handler initialized - Steam will see controller input being consumed');
|
||||
}
|
||||
|
||||
function handleGamepadConnected(event) {
|
||||
const gamepad = event.gamepad;
|
||||
gamepadLog('Gamepad connected:', gamepad.index, gamepad.id);
|
||||
|
||||
gamepadState.gamepads[gamepad.index] = {
|
||||
id: gamepad.id,
|
||||
index: gamepad.index,
|
||||
connected: true,
|
||||
mapping: gamepad.mapping,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
gamepadState.connectedCount++;
|
||||
|
||||
if (gamepadState.activeGamepadIndex === null) {
|
||||
gamepadState.activeGamepadIndex = gamepad.index;
|
||||
}
|
||||
|
||||
gamepadState.buttonStates[gamepad.index] = {};
|
||||
emitGamepadEvent('connect', { gamepad, index: gamepad.index, id: gamepad.id });
|
||||
}
|
||||
|
||||
function handleGamepadDisconnected(event) {
|
||||
const gamepad = event.gamepad;
|
||||
gamepadLog('Gamepad disconnected:', gamepad.index, gamepad.id);
|
||||
|
||||
if (gamepadState.gamepads[gamepad.index]) {
|
||||
delete gamepadState.gamepads[gamepad.index];
|
||||
gamepadState.connectedCount--;
|
||||
}
|
||||
|
||||
delete gamepadState.buttonStates[gamepad.index];
|
||||
|
||||
if (gamepadState.activeGamepadIndex === gamepad.index) {
|
||||
gamepadState.activeGamepadIndex = null;
|
||||
const gamepads = navigator.getGamepads();
|
||||
for (let i = 0; i < gamepads.length; i++) {
|
||||
if (gamepads[i]) {
|
||||
gamepadState.activeGamepadIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emitGamepadEvent('disconnect', { index: gamepad.index, id: gamepad.id });
|
||||
}
|
||||
|
||||
function scanGamepads() {
|
||||
const gamepads = navigator.getGamepads();
|
||||
for (let i = 0; i < gamepads.length; i++) {
|
||||
const gamepad = gamepads[i];
|
||||
if (gamepad && !gamepadState.gamepads[gamepad.index]) {
|
||||
gamepadLog('Found pre-connected gamepad:', gamepad.index, gamepad.id);
|
||||
gamepadState.gamepads[gamepad.index] = {
|
||||
id: gamepad.id,
|
||||
index: gamepad.index,
|
||||
connected: true,
|
||||
mapping: gamepad.mapping,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
gamepadState.connectedCount++;
|
||||
if (gamepadState.activeGamepadIndex === null) {
|
||||
gamepadState.activeGamepadIndex = gamepad.index;
|
||||
}
|
||||
gamepadState.buttonStates[gamepad.index] = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startGamepadPolling() {
|
||||
if (gamepadState.rafId !== null) return;
|
||||
|
||||
function pollLoop(timestamp) {
|
||||
// CRITICAL: This call to getGamepads() tells Steam we're consuming gamepad input
|
||||
const gamepads = navigator.getGamepads();
|
||||
|
||||
for (let i = 0; i < gamepads.length; i++) {
|
||||
const gamepad = gamepads[i];
|
||||
if (gamepad) {
|
||||
processGamepadInput(gamepad);
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic scan for newly connected gamepads
|
||||
if (timestamp % 1000 < 20) {
|
||||
scanGamepads();
|
||||
}
|
||||
|
||||
gamepadState.rafId = requestAnimationFrame(pollLoop);
|
||||
}
|
||||
|
||||
gamepadState.rafId = requestAnimationFrame(pollLoop);
|
||||
gamepadLog('Started gamepad polling');
|
||||
}
|
||||
|
||||
function processGamepadInput(gamepad) {
|
||||
const index = gamepad.index;
|
||||
const buttonState = gamepadState.buttonStates[index] || {};
|
||||
let hasInput = false;
|
||||
|
||||
// Process buttons
|
||||
for (let i = 0; i < gamepad.buttons.length; i++) {
|
||||
const button = gamepad.buttons[i];
|
||||
const wasPressed = buttonState[`b${i}`] || false;
|
||||
const isPressed = button.pressed || button.value > 0.5;
|
||||
|
||||
if (isPressed !== wasPressed) {
|
||||
buttonState[`b${i}`] = isPressed;
|
||||
hasInput = true;
|
||||
emitGamepadEvent('button', { gamepad, index, button: i, pressed: isPressed, value: button.value });
|
||||
}
|
||||
}
|
||||
|
||||
// Process axes
|
||||
for (let i = 0; i < gamepad.axes.length; i++) {
|
||||
const value = gamepad.axes[i];
|
||||
const prevValue = buttonState[`a${i}`] || 0;
|
||||
|
||||
if (Math.abs(value - prevValue) > 0.01) {
|
||||
buttonState[`a${i}`] = value;
|
||||
if (Math.abs(value) > GAMEPAD_CONFIG.STICK_DEADZONE) {
|
||||
hasInput = true;
|
||||
emitGamepadEvent('axis', { gamepad, index, axis: i, value });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
gamepadState.buttonStates[index] = buttonState;
|
||||
|
||||
if (hasInput) {
|
||||
emitGamepadEvent('input', { gamepad, index });
|
||||
}
|
||||
}
|
||||
|
||||
function emitGamepadEvent(type, data) {
|
||||
// Dispatch as CustomEvent for renderer scripts to listen to
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent(`nebula-gamepad-${type}`, { detail: data }));
|
||||
} catch (err) {
|
||||
// Ignore errors if CustomEvent isn't available
|
||||
}
|
||||
}
|
||||
|
||||
function getActiveGamepad() {
|
||||
if (gamepadState.activeGamepadIndex === null) return null;
|
||||
const gamepads = navigator.getGamepads();
|
||||
return gamepads[gamepadState.activeGamepadIndex] || null;
|
||||
}
|
||||
|
||||
function getConnectedGamepads() {
|
||||
const gamepads = navigator.getGamepads();
|
||||
return Array.from(gamepads).filter(gp => gp !== null);
|
||||
}
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (gamepadState.rafId !== null) {
|
||||
cancelAnimationFrame(gamepadState.rafId);
|
||||
gamepadState.rafId = null;
|
||||
}
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// EARLY GAMEPAD INITIALIZATION - Critical for Steam Deck
|
||||
// =============================================================================
|
||||
// Initialize gamepad polling as EARLY as possible to signal Steam Input
|
||||
// that this app handles controller input natively. This MUST happen before
|
||||
// Steam decides to apply mouse/keyboard emulation.
|
||||
//
|
||||
// We try to initialize immediately when preload runs, not waiting for DOMContentLoaded,
|
||||
// because Steam's input layer makes decisions very early in the process lifecycle.
|
||||
// =============================================================================
|
||||
|
||||
// Try immediate initialization (works in most Electron contexts)
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && navigator.getGamepads) {
|
||||
// Start polling immediately - this is the key signal to Steam
|
||||
initGamepadHandler();
|
||||
console.log('[NebulaGamepad] Early initialization successful - Steam should recognize controller input');
|
||||
}
|
||||
} catch (e) {
|
||||
// Will retry on DOMContentLoaded
|
||||
console.log('[NebulaGamepad] Early init deferred, will retry on DOM ready');
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DOM READY & INITIALIZATION
|
||||
// =============================================================================
|
||||
|
||||
// Cache DOM references for performance
|
||||
let domReady = false;
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
domReady = true;
|
||||
console.log("Browser UI loaded.");
|
||||
|
||||
// Re-initialize gamepad handler if early init failed
|
||||
if (!gamepadState.initialized) {
|
||||
initGamepadHandler();
|
||||
}
|
||||
});
|
||||
|
||||
// Optimized API exposure with error handling and caching
|
||||
const electronAPI = {
|
||||
send: (ch, ...args) => {
|
||||
try {
|
||||
return ipcRenderer.send(ch, ...args);
|
||||
} catch (err) {
|
||||
console.error('IPC send error:', err);
|
||||
}
|
||||
},
|
||||
// Send message to embedding page (webview host) or to BrowserView host
|
||||
sendToHost: (ch, ...args) => {
|
||||
try {
|
||||
// If running in BrowserView context, ALWAYS use browserview-host-message
|
||||
if (nebulaTabId) {
|
||||
return ipcRenderer.send('browserview-host-message', { tabId: nebulaTabId, channel: ch, args });
|
||||
}
|
||||
// Otherwise try ipcRenderer.sendToHost (for webview contexts)
|
||||
if (typeof ipcRenderer.sendToHost === 'function') {
|
||||
return ipcRenderer.sendToHost(ch, ...args);
|
||||
}
|
||||
// Final fallback
|
||||
return ipcRenderer.send(ch, ...args);
|
||||
} catch (err) {
|
||||
console.error('IPC sendToHost error:', err);
|
||||
}
|
||||
},
|
||||
invoke: (ch, ...args) => {
|
||||
try {
|
||||
return ipcRenderer.invoke(ch, ...args);
|
||||
} catch (err) {
|
||||
console.error('IPC invoke error:', err);
|
||||
return Promise.reject(err);
|
||||
}
|
||||
},
|
||||
on: (ch, fn) => {
|
||||
try {
|
||||
return ipcRenderer.on(ch, (e, ...args) => fn(...args));
|
||||
} catch (err) {
|
||||
console.error('IPC on error:', err);
|
||||
}
|
||||
},
|
||||
// Add removeListener for cleanup
|
||||
removeListener: (ch, fn) => {
|
||||
try {
|
||||
return ipcRenderer.removeListener(ch, fn);
|
||||
} catch (err) {
|
||||
console.error('IPC removeListener error:', err);
|
||||
}
|
||||
},
|
||||
toggleDevTools: () => {
|
||||
try {
|
||||
return ipcRenderer.invoke('open-devtools');
|
||||
} catch (err) {
|
||||
console.error('IPC open-devtools error:', err);
|
||||
return Promise.reject(err);
|
||||
}
|
||||
},
|
||||
openLocalFile: async () => {
|
||||
try {
|
||||
return await ipcRenderer.invoke('show-open-file-dialog');
|
||||
} catch (err) {
|
||||
console.error('IPC openLocalFile error:', err);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
showContextMenu: (params) => {
|
||||
try {
|
||||
return ipcRenderer.invoke('show-context-menu', params);
|
||||
} catch (err) {
|
||||
console.error('IPC showContextMenu error:', err);
|
||||
}
|
||||
},
|
||||
saveImageToDisk: async (suggestedName, dataUrl) => ipcRenderer.invoke('save-image-from-dataurl', { suggestedName, dataUrl }),
|
||||
saveImageFromNet: async (url) => ipcRenderer.invoke('save-image-from-url', { url })
|
||||
};
|
||||
|
||||
// Provide absolute path to the renderer preload for webview guests so
|
||||
// webview `preload` attributes use an absolute, resolvable path on all platforms.
|
||||
const webviewPreloadAbsolutePath = pathModule ? pathModule.join(__dirname, 'preload.js') : null;
|
||||
electronAPI.getWebviewPreloadPath = () => webviewPreloadAbsolutePath;
|
||||
|
||||
// Fixup any static <webview preload="..."> attributes in the DOM early so
|
||||
// guests receive an absolute path instead of a relative one that may fail
|
||||
// to resolve inside the guest process.
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
try {
|
||||
if (webviewPreloadAbsolutePath) {
|
||||
const els = document.querySelectorAll('webview[preload]');
|
||||
for (const el of els) {
|
||||
try { el.setAttribute('preload', webviewPreloadAbsolutePath); } catch {};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// non-fatal
|
||||
}
|
||||
});
|
||||
|
||||
// Cache for bookmarks to reduce IPC calls
|
||||
let bookmarksCache = null;
|
||||
let bookmarksCacheTime = 0;
|
||||
const CACHE_DURATION = 5000; // 5 seconds
|
||||
|
||||
const bookmarksAPI = {
|
||||
load: async () => {
|
||||
const now = Date.now();
|
||||
if (bookmarksCache && (now - bookmarksCacheTime) < CACHE_DURATION) {
|
||||
return bookmarksCache;
|
||||
}
|
||||
try {
|
||||
bookmarksCache = await ipcRenderer.invoke('load-bookmarks');
|
||||
bookmarksCacheTime = now;
|
||||
return bookmarksCache;
|
||||
} catch (err) {
|
||||
console.error('Bookmarks load error:', err);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
save: async (data) => {
|
||||
try {
|
||||
bookmarksCache = data; // Update cache immediately
|
||||
bookmarksCacheTime = Date.now();
|
||||
return await ipcRenderer.invoke('save-bookmarks', data);
|
||||
} catch (err) {
|
||||
console.error('Bookmarks save error:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Expose APIs to main world
|
||||
contextBridge.exposeInMainWorld('electronAPI', electronAPI);
|
||||
contextBridge.exposeInMainWorld('bookmarksAPI', bookmarksAPI);
|
||||
|
||||
// Gamepad API - Access to the gamepad handler running in the preload context
|
||||
// The handler actively polls navigator.getGamepads() to signal to Steam that
|
||||
// the app is consuming controller input (prevents mouse emulation on Steam Deck)
|
||||
contextBridge.exposeInMainWorld('gamepadAPI', {
|
||||
// Check if gamepad handler is initialized
|
||||
isAvailable: () => gamepadState.initialized,
|
||||
|
||||
// Check if any gamepad is connected
|
||||
isConnected: () => gamepadState.connectedCount > 0,
|
||||
|
||||
// Get connected gamepads info
|
||||
getConnected: () => {
|
||||
const gamepads = getConnectedGamepads();
|
||||
return gamepads.map(gp => ({
|
||||
id: gp.id,
|
||||
index: gp.index,
|
||||
mapping: gp.mapping,
|
||||
buttons: gp.buttons.length,
|
||||
axes: gp.axes.length,
|
||||
}));
|
||||
},
|
||||
|
||||
// Get the active gamepad's current state
|
||||
getActive: () => {
|
||||
const gp = getActiveGamepad();
|
||||
if (!gp) return null;
|
||||
return {
|
||||
id: gp.id,
|
||||
index: gp.index,
|
||||
mapping: gp.mapping,
|
||||
buttons: Array.from(gp.buttons).map((b, i) => ({ index: i, pressed: b.pressed, value: b.value })),
|
||||
axes: Array.from(gp.axes),
|
||||
};
|
||||
},
|
||||
|
||||
// Enable debug mode
|
||||
setDebug: (enabled) => {
|
||||
GAMEPAD_CONFIG.DEBUG = !!enabled;
|
||||
},
|
||||
|
||||
// Get handler state for debugging
|
||||
getState: () => ({
|
||||
initialized: gamepadState.initialized,
|
||||
connectedCount: gamepadState.connectedCount,
|
||||
activeGamepadIndex: gamepadState.activeGamepadIndex,
|
||||
isPolling: gamepadState.rafId !== null,
|
||||
}),
|
||||
});
|
||||
|
||||
// Minimal about API for settings page
|
||||
contextBridge.exposeInMainWorld('aboutAPI', {
|
||||
getInfo: () => ipcRenderer.invoke('get-about-info')
|
||||
});
|
||||
|
||||
// Big Picture Mode API - Steam Deck / Console UI
|
||||
// Note: Big Picture Mode now opens in the main window (not a separate window) to keep resources low
|
||||
// and prevent SteamOS from creating desktop mode alongside when auto-launching.
|
||||
contextBridge.exposeInMainWorld('bigPictureAPI', {
|
||||
// Get screen info to determine if Big Picture Mode is recommended
|
||||
getScreenInfo: () => ipcRenderer.invoke('get-screen-info'),
|
||||
// Check if device is likely a Steam Deck or handheld
|
||||
isSuggested: () => ipcRenderer.invoke('is-bigpicture-suggested'),
|
||||
// Check if currently in Big Picture Mode
|
||||
isActive: () => ipcRenderer.invoke('is-in-bigpicture'),
|
||||
// Launch Big Picture Mode (navigates main window to Big Picture UI)
|
||||
launch: () => ipcRenderer.invoke('launch-bigpicture'),
|
||||
// Exit Big Picture Mode (navigates main window back to desktop UI)
|
||||
exit: () => ipcRenderer.invoke('exit-bigpicture'),
|
||||
// Navigate to URL (from Big Picture Mode)
|
||||
navigate: (url) => ipcRenderer.send('bigpicture-navigate', url),
|
||||
// Send input event to a webview (for virtual cursor clicks)
|
||||
sendInputEvent: (webContentsId, inputEvent) =>
|
||||
ipcRenderer.invoke('webview-send-input-event', { webContentsId, inputEvent })
|
||||
});
|
||||
|
||||
// Relay context-menu commands from main to active renderer context (open new tabs etc.)
|
||||
ipcRenderer.on('context-menu-command', (event, payload) => {
|
||||
window.dispatchEvent(new CustomEvent('nebula-context-command', { detail: payload }));
|
||||
});
|
||||
|
||||
// Downloads API exposed to renderer
|
||||
contextBridge.exposeInMainWorld('downloadsAPI', {
|
||||
list: () => ipcRenderer.invoke('downloads-get-all'),
|
||||
action: (id, action) => ipcRenderer.invoke('downloads-action', { id, action }),
|
||||
clearCompleted: () => ipcRenderer.invoke('downloads-clear-completed'),
|
||||
onStarted: (handler) => ipcRenderer.on('downloads-started', (_e, payload) => handler(payload)),
|
||||
onUpdated: (handler) => ipcRenderer.on('downloads-updated', (_e, payload) => handler(payload)),
|
||||
onDone: (handler) => ipcRenderer.on('downloads-done', (_e, payload) => handler(payload)),
|
||||
onCleared: (handler) => ipcRenderer.on('downloads-cleared', handler),
|
||||
onScanStarted: (handler) => ipcRenderer.on('downloads-scan-started', (_e, payload) => handler(payload)),
|
||||
onScanResult: (handler) => ipcRenderer.on('downloads-scan-result', (_e, payload) => handler(payload))
|
||||
});
|
||||
|
||||
// Auto-Updater API exposed to renderer
|
||||
contextBridge.exposeInMainWorld('updaterAPI', {
|
||||
checkForUpdates: () => ipcRenderer.invoke('check-for-updates'),
|
||||
downloadUpdate: () => ipcRenderer.invoke('download-update'),
|
||||
installUpdate: () => ipcRenderer.invoke('install-update'),
|
||||
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||
onUpdateStatus: (handler) => ipcRenderer.on('update-status', (_e, payload) => handler(payload))
|
||||
});
|
||||
|
||||
// First-Time Setup API
|
||||
contextBridge.exposeInMainWorld('api', {
|
||||
// Check if this is the first run
|
||||
isFirstRun: () => ipcRenderer.invoke('is-first-run'),
|
||||
// Get all available themes
|
||||
getAllThemes: () => ipcRenderer.invoke('get-all-themes'),
|
||||
// Apply a theme
|
||||
applyTheme: (themeId) => ipcRenderer.invoke('apply-theme', themeId),
|
||||
// Check if Nebula is the default browser
|
||||
isDefaultBrowser: () => ipcRenderer.invoke('is-default-browser'),
|
||||
// Set Nebula as the default browser
|
||||
setAsDefaultBrowser: () => ipcRenderer.invoke('set-as-default-browser'),
|
||||
// Open OS default browser settings
|
||||
openDefaultBrowserSettings: () => ipcRenderer.invoke('open-default-browser-settings'),
|
||||
// Complete first-run setup
|
||||
completeFirstRun: (data) => ipcRenderer.invoke('complete-first-run', data),
|
||||
// Get first-run data
|
||||
getFirstRunData: () => ipcRenderer.invoke('get-first-run-data')
|
||||
});
|
||||
|
||||
// ----------------------------------------
|
||||
// Plugin renderer preloads
|
||||
// ----------------------------------------
|
||||
// We request a list of absolute file paths from main and require() them here.
|
||||
// Each file can optionally call contextBridge.exposeInMainWorld to add APIs.
|
||||
(async () => {
|
||||
try {
|
||||
const preloads = await ipcRenderer.invoke('plugins-get-renderer-preloads');
|
||||
if (Array.isArray(preloads)) {
|
||||
for (const p of preloads) {
|
||||
try {
|
||||
// eslint-disable-next-line global-require, import/no-dynamic-require
|
||||
require(p);
|
||||
} catch (e) {
|
||||
console.error('[Plugins] Failed to load renderer preload:', p, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Plugins] No renderer preloads:', e);
|
||||
}
|
||||
})();
|
||||
@@ -1,215 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>GPU Diagnostics - Nebula Browser</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 20px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.status {
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.status.good { background: #d4edda; color: #155724; }
|
||||
.status.warning { background: #fff3cd; color: #856404; }
|
||||
.status.error { background: #f8d7da; color: #721c24; }
|
||||
button {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin: 5px;
|
||||
}
|
||||
button:hover { background: #0056b3; }
|
||||
pre {
|
||||
background: #f8f9fa;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
.canvas-test {
|
||||
border: 1px solid #ccc;
|
||||
margin: 10px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>GPU Diagnostics</h1>
|
||||
|
||||
<div id="gpu-status" class="status">
|
||||
<h3>GPU Status</h3>
|
||||
<p>Loading GPU information...</p>
|
||||
</div>
|
||||
|
||||
<div class="status">
|
||||
<h3>WebGL Test</h3>
|
||||
<canvas id="webgl-canvas" class="canvas-test" width="300" height="150"></canvas>
|
||||
<p id="webgl-status">Testing WebGL...</p>
|
||||
</div>
|
||||
|
||||
<div class="status">
|
||||
<h3>Canvas 2D Acceleration Test</h3>
|
||||
<canvas id="canvas2d" class="canvas-test" width="300" height="150"></canvas>
|
||||
<p id="canvas2d-status">Testing Canvas 2D...</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>Actions</h3>
|
||||
<button onclick="refreshGPUInfo()">Refresh GPU Info</button>
|
||||
<button onclick="forceGC()">Force Garbage Collection</button>
|
||||
<button onclick="applyFallback(1)">Apply GPU Fallback Level 1</button>
|
||||
<button onclick="applyFallback(2)">Apply GPU Fallback Level 2</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>Detailed GPU Information</h3>
|
||||
<pre id="gpu-details">Loading...</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function refreshGPUInfo() {
|
||||
try {
|
||||
const gpuInfo = await window.electronAPI.invoke('get-gpu-info');
|
||||
const statusDiv = document.getElementById('gpu-status');
|
||||
const detailsDiv = document.getElementById('gpu-details');
|
||||
|
||||
if (gpuInfo.error) {
|
||||
statusDiv.className = 'status error';
|
||||
statusDiv.innerHTML = `<h3>GPU Status</h3><p>Error: ${gpuInfo.error}</p>`;
|
||||
} else {
|
||||
const isGPUWorking = checkGPUFeatures(gpuInfo.featureStatus);
|
||||
statusDiv.className = `status ${isGPUWorking ? 'good' : 'warning'}`;
|
||||
statusDiv.innerHTML = `
|
||||
<h3>GPU Status</h3>
|
||||
<p><strong>Hardware Acceleration:</strong> ${isGPUWorking ? 'Enabled' : 'Disabled/Limited'}</p>
|
||||
<p><strong>Fallback Level:</strong> ${gpuInfo.fallbackStatus?.fallbackLevel || 0}</p>
|
||||
<p><strong>GPU Enabled:</strong> ${gpuInfo.fallbackStatus?.gpuEnabled ? 'Yes' : 'No'}</p>
|
||||
`;
|
||||
}
|
||||
|
||||
detailsDiv.textContent = JSON.stringify(gpuInfo, null, 2);
|
||||
} catch (err) {
|
||||
console.error('Failed to get GPU info:', err);
|
||||
document.getElementById('gpu-status').innerHTML = `<h3>GPU Status</h3><p>Error: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function checkGPUFeatures(features) {
|
||||
const criticalFeatures = ['gpu_compositing', 'webgl', 'webgl2'];
|
||||
return criticalFeatures.some(feature =>
|
||||
features[feature] && !features[feature].includes('disabled')
|
||||
);
|
||||
}
|
||||
|
||||
async function forceGC() {
|
||||
try {
|
||||
await window.electronAPI.invoke('force-gc');
|
||||
alert('Garbage collection completed');
|
||||
} catch (err) {
|
||||
alert('Failed to force GC: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyFallback(level) {
|
||||
try {
|
||||
const result = await window.electronAPI.invoke('apply-gpu-fallback', level);
|
||||
if (result.success) {
|
||||
alert(`Applied GPU fallback level ${level}. App restart may be required.`);
|
||||
} else {
|
||||
alert('Failed to apply fallback: ' + result.error);
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Failed to apply fallback: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Test WebGL
|
||||
function testWebGL() {
|
||||
const canvas = document.getElementById('webgl-canvas');
|
||||
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
|
||||
const status = document.getElementById('webgl-status');
|
||||
|
||||
if (gl) {
|
||||
// Draw a simple triangle
|
||||
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
|
||||
gl.shaderSource(vertexShader, `
|
||||
attribute vec2 position;
|
||||
void main() {
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
}
|
||||
`);
|
||||
gl.compileShader(vertexShader);
|
||||
|
||||
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
|
||||
gl.shaderSource(fragmentShader, `
|
||||
precision mediump float;
|
||||
void main() {
|
||||
gl_Color = vec4(0.0, 1.0, 0.0, 1.0);
|
||||
}
|
||||
`);
|
||||
gl.compileShader(fragmentShader);
|
||||
|
||||
status.textContent = 'WebGL: Available ✓';
|
||||
status.parentElement.className = 'status good';
|
||||
|
||||
// Clear with green color to show it's working
|
||||
gl.clearColor(0.0, 0.8, 0.0, 1.0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
} else {
|
||||
status.textContent = 'WebGL: Not Available ✗';
|
||||
status.parentElement.className = 'status error';
|
||||
}
|
||||
}
|
||||
|
||||
// Test Canvas 2D
|
||||
function testCanvas2D() {
|
||||
const canvas = document.getElementById('canvas2d');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const status = document.getElementById('canvas2d-status');
|
||||
|
||||
try {
|
||||
// Draw some graphics to test acceleration
|
||||
const gradient = ctx.createLinearGradient(0, 0, 300, 0);
|
||||
gradient.addColorStop(0, '#ff0000');
|
||||
gradient.addColorStop(1, '#0000ff');
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, 300, 150);
|
||||
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.font = '20px Arial';
|
||||
ctx.fillText('Canvas 2D Working!', 50, 80);
|
||||
|
||||
status.textContent = 'Canvas 2D: Working ✓';
|
||||
status.parentElement.className = 'status good';
|
||||
} catch (err) {
|
||||
status.textContent = 'Canvas 2D: Error - ' + err.message;
|
||||
status.parentElement.className = 'status error';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize tests
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
refreshGPUInfo();
|
||||
testWebGL();
|
||||
testCanvas2D();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,112 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Nebula Browser</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<style>
|
||||
/* Removed custom draggable bar CSS to allow use of native title bar */
|
||||
|
||||
:root { --resize-border: 8px; }
|
||||
|
||||
body {
|
||||
padding: var(--resize-border);
|
||||
margin: 0;
|
||||
height: calc(100vh - 2 * var(--resize-border));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
::placeholder {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
/* Adjust the color and transparency as needed */
|
||||
}
|
||||
|
||||
#view-host {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Windows title bar controls wrapper - sits above tab bar -->
|
||||
<div id="titlebar-container">
|
||||
<div id="tab-bar"></div>
|
||||
<div id="window-controls">
|
||||
<button id="min-btn" title="Minimize" aria-label="Minimize">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<path d="M0 5h10" stroke="currentColor" stroke-width="1" fill="none"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="max-btn" title="Maximize" aria-label="Maximize">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" class="maximize-icon">
|
||||
<rect x="0.5" y="0.5" width="9" height="9" stroke="currentColor" stroke-width="1" fill="none"/>
|
||||
</svg>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" class="restore-icon" style="display:none">
|
||||
<path d="M2.5 0.5h7v7M0.5 2.5h7v7h-7z" stroke="currentColor" stroke-width="1" fill="none"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="close-btn" title="Close" aria-label="Close">
|
||||
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||
<path d="M0 0l10 10M10 0l-10 10" stroke="currentColor" stroke-width="1" fill="none"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="nav">
|
||||
<div class="nav-left">
|
||||
<button onclick="goBack()">←</button>
|
||||
<button onclick="goForward()">→</button>
|
||||
<button id="reload-btn">⟳</button>
|
||||
</div>
|
||||
|
||||
<div class="nav-center">
|
||||
<input id="url" type="text" placeholder="Type URL here" />
|
||||
<button onclick="navigate()">Go</button>
|
||||
</div>
|
||||
|
||||
<div class="nav-right">
|
||||
<div class="downloads-wrapper">
|
||||
<button id="downloads-btn" title="Downloads" aria-label="Downloads">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 3v12"/>
|
||||
<path d="M7 10l5 5 5-5"/>
|
||||
<path d="M5 21h14"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="downloads-popup" class="hidden">
|
||||
<div class="downloads-pop-header">
|
||||
<span>Downloads</span>
|
||||
<button id="downloads-show-all">Show all</button>
|
||||
</div>
|
||||
<div id="downloads-list" class="downloads-pop-list"></div>
|
||||
<div id="downloads-empty" class="downloads-empty">No downloads</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="menu-wrapper">
|
||||
<button id="menu-btn">☰</button>
|
||||
<div id="menu-popup" class="hidden">
|
||||
<button id="open-settings-btn">Settings</button>
|
||||
<!-- You can add more options here -->
|
||||
<button id="bigpicture-btn" title="Launch Big Picture Mode (Controller/Steam Deck UI)">🎮 Big Picture Mode</button>
|
||||
<button id="devtools-btn" title="Open / Close Developer Tools">Toggle Developer Tools</button>
|
||||
<div class="zoom-controls">
|
||||
<button id="zoom-out-btn">-</button>
|
||||
<span id="zoom-percent">100%</span>
|
||||
<button id="zoom-in-btn">+</button>
|
||||
</div>
|
||||
<button id="hard-reload-btn">Hard Reload (Ignore Cache)</button>
|
||||
<button id="fresh-reload-btn">Reload Fresh (Add Cache-Buster)</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="view-host"></div>
|
||||
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,59 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Nebot</title>
|
||||
<link rel="stylesheet" href="../plugins/nebot/page.css" onerror="this.remove()"/>
|
||||
<style>
|
||||
body { margin:0; font-family: system-ui,-apple-system,Segoe UI,Roboto,sans-serif; background:#12141c; color:#e6e8ef; }
|
||||
.fallback { max-width:620px; margin:60px auto; padding:32px 36px; background:#1d222e; border:1px solid rgba(255,255,255,0.08); border-radius:18px; }
|
||||
.fallback h1 { margin:0 0 12px; font-size:28px; background:linear-gradient(90deg,#a48bff,#6cb6ff); -webkit-background-clip:text; color:transparent; }
|
||||
.fallback p { line-height:1.55; }
|
||||
.tip { background:#232b38; padding:10px 14px; border-radius:10px; font-size:13px; margin-top:18px; border:1px solid rgba(255,255,255,0.08); }
|
||||
.err { color:#ff6d7d; font-weight:600; }
|
||||
#mount { min-height:400px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="mount"></div>
|
||||
<script>
|
||||
(async function(){
|
||||
// Wait a tick so plugin preload (renderer-preload) runs and exposes window.ollamaChat
|
||||
const mount = document.getElementById('mount');
|
||||
function showFallback(reason){
|
||||
mount.innerHTML = `<div class="fallback">`+
|
||||
`<h1>Nebot</h1>`+
|
||||
`<p>The Nebot plugin page could not load automatically.</p>`+
|
||||
(reason?`<p class='err'>${reason}</p>`:'')+
|
||||
`<div class="tip"><strong>How to fix</strong><br/>1. Ensure the Nebot plugin folder exists at plugins/nebot.<br/>2. Confirm plugin is enabled (manifest enabled: true).<br/>3. Restart the app so the plugin manager registers pages.</div>`+
|
||||
`</div>`;
|
||||
}
|
||||
try {
|
||||
// Try to fetch plugin page HTML directly
|
||||
const res = await fetch('../plugins/nebot/page.html');
|
||||
if(!res.ok){ showFallback('Missing page.html (status '+res.status+').'); return; }
|
||||
const html = await res.text();
|
||||
// Simple sandboxed injection
|
||||
mount.innerHTML = html;
|
||||
// The injected page expects its CSS & JS relative to itself; adjust asset paths
|
||||
const fixLinks = mount.querySelectorAll('link[rel="stylesheet"], script[src]');
|
||||
fixLinks.forEach(el=>{
|
||||
const attr = el.tagName==='SCRIPT'?'src':'href';
|
||||
if(el.getAttribute(attr) && !/plugins\/nebot\//.test(el.getAttribute(attr))){
|
||||
el.setAttribute(attr,'../plugins/nebot/'+el.getAttribute(attr));
|
||||
}
|
||||
});
|
||||
// Inject JS if not already present
|
||||
if(!mount.querySelector('script[data-nebot-page]')){
|
||||
const s=document.createElement('script'); s.dataset.nebotPage='1';
|
||||
// Pass the current URL hash to the page script for debug mode
|
||||
s.src='../plugins/nebot/page.js' + window.location.hash;
|
||||
mount.appendChild(s);
|
||||
}
|
||||
} catch(e){
|
||||
showFallback(e.message||'Unknown error');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,762 +0,0 @@
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
/* Background now driven by theme */
|
||||
background: var(--bg, #0b0d10);
|
||||
color: var(--text, white);
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, Ubuntu, Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* Global variables */
|
||||
:root {
|
||||
/* Space reserved on the left for system window controls (traffic lights on macOS).
|
||||
Applied cross-platform per request to keep a consistent layout. */
|
||||
--window-controls-offset: 80px; /* adjust if needed */
|
||||
/* Space reserved on the right for Windows title bar controls */
|
||||
--window-controls-width: 138px;
|
||||
|
||||
/* Design tokens */
|
||||
--bg: #0b0d10;
|
||||
--surface-1: #11131a;
|
||||
--surface-2: #161925;
|
||||
--surface-3: #1c2030;
|
||||
--text: #e8e8f0;
|
||||
--muted: #a4a7b3;
|
||||
--outline: #2b3040;
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--shadow-1: 0 6px 20px rgba(0,0,0,.35);
|
||||
--shadow-2: 0 12px 36px rgba(0,0,0,.45);
|
||||
--blur: 12px;
|
||||
/* Accent palette */
|
||||
--accent-h: 265;
|
||||
--accent-s: 86%;
|
||||
--accent-l: 62%;
|
||||
--accent: hsl(var(--accent-h) var(--accent-s) var(--accent-l));
|
||||
--accent-600: hsl(var(--accent-h) var(--accent-s) 52%);
|
||||
--accent-700: hsl(var(--accent-h) var(--accent-s) 46%);
|
||||
|
||||
/* URL bar and tab theme colors (defaults) */
|
||||
--url-bar-bg: #1C2030;
|
||||
--url-bar-text: #E0E0E0;
|
||||
--url-bar-border: #3E4652;
|
||||
--tab-bg: #161925;
|
||||
--tab-text: #A4A7B3;
|
||||
--tab-active: #1C2030;
|
||||
--tab-active-text: #E0E0E0;
|
||||
--tab-border: #2B3040;
|
||||
}
|
||||
|
||||
/* TITLEBAR CONTAINER - holds tab bar and window controls */
|
||||
#titlebar-container {
|
||||
display: flex;
|
||||
position: relative;
|
||||
background: var(--tab-bg);
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
/* TAB STRIP */
|
||||
#tab-bar {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
/* Default: small left padding for Windows/Linux (no traffic lights) */
|
||||
padding: 6px 10px 0 10px;
|
||||
background: var(--tab-bg);
|
||||
border-bottom: 1px solid var(--tab-border);
|
||||
overflow-x: auto; /* scroll when many tabs */
|
||||
scrollbar-color: #444 #2a2a3c; /* thumb and track for Firefox */
|
||||
scrollbar-width: thin; /* slimmer track */
|
||||
/* Inherit drag from container */
|
||||
-webkit-app-region: drag;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
/* NAVBAR LAYOUT */
|
||||
#nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: var(--url-bar-bg);
|
||||
gap: 12px;
|
||||
/* flatter header to reduce paint cost */
|
||||
box-shadow: none;
|
||||
/* Ensure the nav sits above embedded <webview> surfaces */
|
||||
position: relative;
|
||||
z-index: 10000;
|
||||
-webkit-backdrop-filter: blur(var(--blur));
|
||||
backdrop-filter: blur(var(--blur));
|
||||
border-bottom: 1px solid var(--url-bar-border);
|
||||
}
|
||||
|
||||
/* Make the top nav a draggable region on macOS when we use a hidden titlebar.
|
||||
Interactive controls inside must opt-out with -webkit-app-region: no-drag. */
|
||||
@supports (-webkit-app-region: drag) {
|
||||
/* Make the top nav a draggable region, but only in its background/gaps. */
|
||||
#nav { -webkit-app-region: drag; user-select: none; }
|
||||
|
||||
/* Interactive controls must explicitly opt-out of dragging. This keeps
|
||||
the larger draggable area while preserving normal click behavior. */
|
||||
#nav button,
|
||||
#nav input,
|
||||
#menu-popup,
|
||||
.tab,
|
||||
.tab *,
|
||||
.new-tab-button,
|
||||
.tab .tab-close,
|
||||
#window-controls {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* Ensure the new-tab button (which sits on the tab strip) is not draggable */
|
||||
#tab-bar .new-tab-button {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-left,
|
||||
.nav-center,
|
||||
.nav-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nav-center {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px; /* pill */
|
||||
/* glassy, accented border */
|
||||
background: var(--url-bar-bg);
|
||||
border: 1px solid var(--url-bar-border);
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.04);
|
||||
}
|
||||
|
||||
#favicon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
#url {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--url-bar-text);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#url::placeholder {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
/* Iconic circular chrome buttons */
|
||||
.nav-left button,
|
||||
.nav-right > button,
|
||||
#reload-btn,
|
||||
#menu-btn {
|
||||
background: color-mix(in srgb, var(--url-bar-bg) 90%, var(--text) 10%);
|
||||
color: var(--text);
|
||||
border: 1px solid color-mix(in srgb, var(--text) 15%, transparent);
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
/* subtle inner highlight adds edge definition */
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--text) 8%, transparent);
|
||||
transition: transform 120ms ease, box-shadow 120ms ease, filter 120ms ease;
|
||||
line-height: 0; /* avoid vertical misalignment for glyphs */
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#downloads-btn svg { display:block; width: 18px; height: 18px; }
|
||||
|
||||
/* Downloads button chrome to match other nav buttons */
|
||||
#downloads-btn {
|
||||
background: color-mix(in srgb, var(--url-bar-bg) 90%, var(--text) 10%);
|
||||
color: var(--text);
|
||||
border: 1px solid color-mix(in srgb, var(--text) 15%, transparent);
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--text) 8%, transparent);
|
||||
transition: transform 120ms ease, box-shadow 120ms ease, filter 120ms ease, color 120ms ease;
|
||||
line-height: 0;
|
||||
padding: 0;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
#downloads-btn:hover { filter: brightness(1.05); box-shadow: 0 4px 14px rgba(0,0,0,0.35); color: var(--text); }
|
||||
#downloads-btn:active { transform: translateY(1px) scale(0.98); }
|
||||
#downloads-btn:focus-visible { outline: none; box-shadow: 0 0 0 2px rgba(123, 97, 255, 0.55); }
|
||||
#downloads-btn:focus { outline: none; box-shadow: none; }
|
||||
|
||||
/* Match home-active chrome variant */
|
||||
body:has(#home-container.active) #downloads-btn {
|
||||
background: color-mix(in srgb, var(--url-bar-bg) 85%, var(--text) 15%);
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--text) 10%, transparent);
|
||||
}
|
||||
|
||||
.nav-left button:hover,
|
||||
.nav-right > button:hover,
|
||||
#reload-btn:hover,
|
||||
#menu-btn:hover {
|
||||
filter: brightness(1.05);
|
||||
box-shadow: 0 4px 14px color-mix(in srgb, var(--bg) 50%, transparent);
|
||||
}
|
||||
|
||||
.nav-left button:active,
|
||||
.nav-right > button:active,
|
||||
#reload-btn:active,
|
||||
#menu-btn:active {
|
||||
transform: translateY(1px) scale(0.98);
|
||||
}
|
||||
|
||||
/* Primary action (Go button) keeps rectangular look but modernized */
|
||||
.nav-center + button,
|
||||
.nav-center button {
|
||||
background: var(--accent);
|
||||
color: var(--text);
|
||||
border: 1px solid transparent;
|
||||
padding: 8px 14px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: transform 120ms ease, box-shadow 120ms ease, filter 120ms ease;
|
||||
}
|
||||
.nav-center + button:hover,
|
||||
.nav-center button:hover { box-shadow: 0 8px 20px color-mix(in srgb, var(--primary) 35%, transparent); }
|
||||
.nav-center + button:active,
|
||||
.nav-center button:active { transform: translateY(1px) scale(0.98); }
|
||||
|
||||
/* MENU DROPDOWN */
|
||||
.menu-wrapper {
|
||||
position: relative;
|
||||
/* keep wrapper on a higher layer so absolute popup can composit above webviews */
|
||||
z-index: 10001;
|
||||
}
|
||||
|
||||
#menu-popup {
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
right: 0;
|
||||
background: color-mix(in srgb, var(--url-bar-bg) 92%, var(--text) 8%);
|
||||
border: 1px solid color-mix(in srgb, var(--primary) 25%, color-mix(in srgb, var(--accent) 18%, transparent));
|
||||
border-radius: 14px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 200px; /* wider dropdown */
|
||||
box-shadow: var(--shadow-1);
|
||||
/* Much higher z-index and force its own compositing layer so it renders above <webview> guests */
|
||||
z-index: 20000;
|
||||
-webkit-transform: translateZ(0);
|
||||
transform: translateZ(0);
|
||||
will-change: transform, opacity;
|
||||
/* animated open/close */
|
||||
opacity: 1;
|
||||
transform-origin: top right;
|
||||
transition: opacity 160ms ease, transform 160ms ease;
|
||||
/* ensure interactions only when visible */
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
-webkit-backdrop-filter: blur(var(--blur));
|
||||
backdrop-filter: blur(var(--blur));
|
||||
}
|
||||
|
||||
#menu-popup button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
transition: background 120ms ease, filter 120ms ease;
|
||||
}
|
||||
|
||||
#menu-popup button:hover {
|
||||
background: color-mix(in srgb, var(--text) 8%, transparent);
|
||||
}
|
||||
|
||||
/* Big Picture Mode button special style */
|
||||
#bigpicture-btn {
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--primary) 15%, transparent) 0%, color-mix(in srgb, var(--accent) 10%, transparent) 100%) !important;
|
||||
border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent) !important;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
#bigpicture-btn:hover {
|
||||
background: linear-gradient(135deg, rgba(123, 46, 255, 0.25) 0%, rgba(0, 198, 255, 0.15) 100%) !important;
|
||||
border-color: rgba(123, 46, 255, 0.5) !important;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Animate menu dropdown instead of removing from flow */
|
||||
#menu-popup.hidden {
|
||||
display: block; /* override global .hidden */
|
||||
opacity: 0;
|
||||
transform: translateY(-8px) scale(0.98);
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Downloads mini popup anchored to the downloads button */
|
||||
.downloads-wrapper { position: relative; z-index: 10002; }
|
||||
#downloads-popup {
|
||||
position: absolute;
|
||||
top: 34px;
|
||||
right: 0;
|
||||
background: color-mix(in srgb, var(--url-bar-bg) 95%, var(--text) 5%);
|
||||
border: 1px solid color-mix(in srgb, var(--primary) 18%, color-mix(in srgb, var(--accent) 14%, transparent));
|
||||
border-radius: 12px;
|
||||
min-width: 280px;
|
||||
box-shadow: var(--shadow-1);
|
||||
padding: 8px;
|
||||
-webkit-backdrop-filter: blur(var(--blur));
|
||||
backdrop-filter: blur(var(--blur));
|
||||
transition: opacity 160ms ease, transform 160ms ease;
|
||||
}
|
||||
#downloads-popup.hidden { opacity: 0; transform: translateY(-6px); visibility: hidden; pointer-events: none; }
|
||||
.downloads-pop-header { display:flex; align-items:center; justify-content:space-between; gap:8px; padding:4px 2px 8px; }
|
||||
.downloads-pop-header > span { font-weight: 600; color: var(--text); }
|
||||
.downloads-pop-header > button { background: transparent; border: none; color: var(--accent); cursor: pointer; }
|
||||
.downloads-pop-list { display:flex; flex-direction: column; gap: 8px; max-height: 280px; overflow: auto; }
|
||||
.downloads-empty { color: var(--tab-text); font-size: 12px; text-align: center; padding: 16px 8px; }
|
||||
.dl-item { display:grid; grid-template-columns: 1fr auto; gap: 6px 8px; background: color-mix(in srgb, var(--text) 3%, transparent); border: 1px solid color-mix(in srgb, var(--text) 6%, transparent); border-radius: 10px; padding: 8px; }
|
||||
.dl-file { font-size: 12px; color: var(--text); white-space: nowrap; text-overflow: ellipsis; overflow: hidden; }
|
||||
.dl-meta { font-size: 11px; color: var(--tab-text); }
|
||||
.dl-actions { display:flex; gap:6px; }
|
||||
.dl-actions button { background: transparent; color: var(--text); border: 1px solid color-mix(in srgb, var(--text) 12%, transparent); border-radius: 8px; padding: 4px 8px; cursor: pointer; }
|
||||
.dl-progress { height: 4px; background: color-mix(in srgb, var(--text) 8%, transparent); border-radius: 3px; overflow: hidden; grid-column: 1 / -1; }
|
||||
.dl-bar { height: 100%; background: var(--accent); width: 0%; transition: width .12s linear; }
|
||||
|
||||
/* Circular progress ring around downloads button */
|
||||
#downloads-btn { position: relative; }
|
||||
#downloads-btn .ring {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 40px; /* slightly larger than 34px button for halo */
|
||||
height: 40px;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
#downloads-btn .ring svg { width: 100%; height: 100%; transform: rotate(-90deg); }
|
||||
#downloads-btn .ring circle.bg { stroke: color-mix(in srgb, var(--text) 15%, transparent); stroke-width: 3; fill: none; }
|
||||
#downloads-btn .ring circle.fg { stroke: var(--accent); stroke-width: 3; fill: none; stroke-linecap: round; transition: stroke-dashoffset .12s linear, opacity .12s ease; }
|
||||
|
||||
/* WEBVIEWS */
|
||||
#webviews {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
/* make sure webviews render on a separate base layer behind nav */
|
||||
z-index: 0;
|
||||
}
|
||||
#webviews.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#webviews webview {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
display: none;
|
||||
}
|
||||
#webviews webview.active {
|
||||
display: flex;
|
||||
}
|
||||
/* When webviews is hidden, collapse its flex size */
|
||||
#webviews.hidden {
|
||||
flex: 0;
|
||||
}
|
||||
|
||||
/* HOME CONTAINER */
|
||||
#home-container {
|
||||
flex: 1;
|
||||
display: none;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
#home-container.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#home-webview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
display: none;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
/* Show home webview when container is active */
|
||||
#home-container.active > #home-webview {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* TABS */
|
||||
.tab {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
-webkit-app-region: no-drag; /* allow HTML5 DnD to work */
|
||||
padding: 4px 10px; /* slimmer padding */
|
||||
margin: 0;
|
||||
height: 28px; /* reduce overall tab height */
|
||||
color: var(--tab-text);
|
||||
/* sleek glass tile */
|
||||
background: var(--tab-bg);
|
||||
border: 1px solid var(--tab-border);
|
||||
border-bottom: none; /* let it visually merge with the strip line */
|
||||
border-radius: 10px 10px 0 0; /* slightly tighter radius */
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
max-width: 260px;
|
||||
min-width: 120px;
|
||||
flex: 0 1 180px; /* like Chrome: shrink when crowded */
|
||||
overflow: hidden;
|
||||
transition: background 120ms ease, color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: var(--tab-bg);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--tab-active-text);
|
||||
background: var(--tab-active);
|
||||
box-shadow: 0 8px 22px color-mix(in srgb, var(--bg) 35%, transparent);
|
||||
}
|
||||
|
||||
.tab.active::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.tab .tab-favicon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 2px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.tab .tab-title {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tab .tab-close {
|
||||
flex: 0 0 auto;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: none;
|
||||
border-radius: 11px;
|
||||
background: transparent;
|
||||
color: var(--tab-text);
|
||||
opacity: 0; /* hidden by default */
|
||||
transition: background 120ms ease, color 120ms ease, opacity 120ms ease;
|
||||
}
|
||||
|
||||
.tab:hover .tab-close,
|
||||
.tab.active .tab-close { opacity: 1; }
|
||||
.tab .tab-close:hover { background: color-mix(in srgb, var(--text) 20%, transparent); color: var(--text); }
|
||||
.tab .tab-close:active { background: color-mix(in srgb, var(--text) 15%, transparent); }
|
||||
|
||||
/* New tab (+) button aligned to the right end of the strip */
|
||||
.new-tab-button {
|
||||
margin-left: 6px;
|
||||
flex: 0 0 auto;
|
||||
width: 26px; /* tighter button */
|
||||
height: 26px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 13px;
|
||||
border: 1px solid color-mix(in srgb, var(--text) 18%, transparent);
|
||||
background: color-mix(in srgb, var(--tab-bg) 90%, var(--text) 10%);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: transform 120ms ease, background 120ms ease, color 120ms ease, border-color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
.new-tab-button:hover { filter: brightness(1.06); box-shadow: 0 6px 16px color-mix(in srgb, var(--bg) 35%, transparent); }
|
||||
.new-tab-button:active { transform: translateY(1px) scale(0.98); }
|
||||
|
||||
/* ZOOM CONTROLS */
|
||||
.zoom-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
background: color-mix(in srgb, var(--text) 4%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--text) 7%, transparent);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.zoom-controls .zoom-label {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
}
|
||||
.zoom-controls button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
.zoom-controls button:hover {
|
||||
background: rgba(255,255,255,0.06);
|
||||
}
|
||||
#zoom-percent {
|
||||
min-width: 38px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* window controls (Windows/Linux only) - Firefox-style next to tabs */
|
||||
#window-controls {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
align-self: stretch;
|
||||
-webkit-app-region: no-drag;
|
||||
background: transparent;
|
||||
border-bottom: 1px solid var(--tab-border);
|
||||
}
|
||||
|
||||
#window-controls button {
|
||||
width: 46px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, color 120ms ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
-webkit-app-region: no-drag;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#window-controls button svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#window-controls button:hover {
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
#window-controls #close-btn:hover {
|
||||
background: #e81123;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#window-controls #close-btn:hover svg path {
|
||||
stroke: white;
|
||||
}
|
||||
|
||||
/* Hide window controls on macOS via body class set by JS */
|
||||
body.platform-darwin #window-controls {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* macOS: add left padding for traffic lights */
|
||||
body.platform-darwin #tab-bar {
|
||||
padding-left: var(--window-controls-offset);
|
||||
}
|
||||
|
||||
#tab-bar::-webkit-scrollbar {
|
||||
height: 8px; /* horizontal scrollbar height */
|
||||
}
|
||||
#tab-bar::-webkit-scrollbar-track {
|
||||
background: #2a2a3c;
|
||||
border-radius: 4px;
|
||||
}
|
||||
#tab-bar::-webkit-scrollbar-thumb {
|
||||
background: #3f4152;
|
||||
border-radius: 4px;
|
||||
}
|
||||
#tab-bar::-webkit-scrollbar-thumb:hover {
|
||||
background: #56586a;
|
||||
}
|
||||
|
||||
/* Tab animations */
|
||||
.tab--flip {
|
||||
transition: transform 180ms cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
.tab--enter {
|
||||
animation: tab-enter 160ms ease-out both;
|
||||
}
|
||||
.tab--closing {
|
||||
animation: tab-exit 140ms ease-in both;
|
||||
}
|
||||
|
||||
/* While dragging a tab, lift it slightly for feedback */
|
||||
.tab--dragging {
|
||||
transform: translateY(-2px) scale(1.04);
|
||||
box-shadow: 0 10px 26px rgba(0,0,0,0.45);
|
||||
z-index: 5;
|
||||
transition: none !important; /* follow cursor without lag */
|
||||
will-change: transform;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Show an insertion hint on hovered tab and nudge it */
|
||||
.tab--drop-before,
|
||||
.tab--drop-after {
|
||||
position: relative;
|
||||
}
|
||||
.tab--drop-before { transform: translateX(-10px); }
|
||||
.tab--drop-after { transform: translateX(10px); }
|
||||
|
||||
.tab--drop-before::before,
|
||||
.tab--drop-after::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(180deg, var(--accent), var(--accent-600));
|
||||
opacity: 0.9;
|
||||
}
|
||||
.tab--drop-before::before { left: 0; }
|
||||
.tab--drop-after::after { right: 0; }
|
||||
|
||||
@keyframes tab-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes tab-exit {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px) scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
/* Respect reduced motion preferences */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus rings for accessibility */
|
||||
.nav-left button:focus-visible,
|
||||
.nav-right > button:focus-visible,
|
||||
#reload-btn:focus-visible,
|
||||
.menu-wrapper #menu-btn:focus-visible,
|
||||
.new-tab-button:focus-visible,
|
||||
#menu-popup button:focus-visible,
|
||||
.zoom-controls button:focus-visible,
|
||||
.tab .tab-close:focus-visible,
|
||||
#window-controls button:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(123, 97, 255, 0.55);
|
||||
}
|
||||
|
||||
/* Keyboard-only ring around the entire address bar (input + Go) */
|
||||
.nav-center:has(:focus-visible) {
|
||||
box-shadow: 0 0 0 2px rgba(123, 97, 255, 0.55), inset 0 1px 0 rgba(255,255,255,0.05);
|
||||
}
|
||||
/* Fallback for engines without :has support */
|
||||
@supports not selector(.nav-center:has(:focus-visible)) {
|
||||
.nav-center:focus-within {
|
||||
box-shadow: 0 0 0 2px rgba(123, 97, 255, 0.45), inset 0 1px 0 rgba(255,255,255,0.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* Remove default click outline but keep keyboard focus via :focus-visible */
|
||||
.nav-left button:focus,
|
||||
.nav-right > button:focus,
|
||||
#reload-btn:focus,
|
||||
#menu-btn:focus,
|
||||
#url:focus,
|
||||
.nav-center button:focus,
|
||||
.new-tab-button:focus,
|
||||
#menu-popup button:focus,
|
||||
.zoom-controls button:focus,
|
||||
.tab .tab-close:focus,
|
||||
#window-controls button:focus {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Stronger chrome contrast when Home is visible - uses theme variables */
|
||||
body:has(#home-container.active) #tab-bar {
|
||||
background: color-mix(in srgb, var(--tab-bg) 92%, black);
|
||||
border-bottom-color: color-mix(in srgb, var(--tab-border) 80%, transparent);
|
||||
box-shadow: 0 10px 24px -12px rgba(0,0,0,0.6);
|
||||
}
|
||||
|
||||
body:has(#home-container.active) #nav {
|
||||
background: color-mix(in srgb, var(--url-bar-bg) 92%, black);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--url-bar-border) 80%, transparent);
|
||||
box-shadow: 0 14px 36px -16px rgba(0,0,0,0.7);
|
||||
}
|
||||
|
||||
body:has(#home-container.active) .nav-center {
|
||||
background: color-mix(in srgb, var(--url-bar-bg) 96%, black);
|
||||
border: 1px solid color-mix(in srgb, var(--primary, var(--accent)) 45%, transparent);
|
||||
}
|
||||
|
||||
body:has(#home-container.active) .nav-left button,
|
||||
body:has(#home-container.active) .nav-right > button,
|
||||
body:has(#home-container.active) #reload-btn,
|
||||
body:has(#home-container.active) #menu-btn {
|
||||
/* slightly lighter than nav to pop over Home - uses theme colors */
|
||||
background: color-mix(in srgb, var(--url-bar-bg) 85%, var(--text) 15%);
|
||||
border: 1px solid color-mix(in srgb, var(--text) 20%, transparent);
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--text) 10%, transparent);
|
||||
}
|
||||
|
||||
/* Elevate active tab a touch more over home */
|
||||
body:has(#home-container.active) .tab.active {
|
||||
box-shadow: 0 10px 26px -8px rgba(0,0,0,0.6);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
[]
|
||||
@@ -1,79 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Nebula Browser Setup Script
|
||||
# This script installs dependencies and fixes Electron sandbox permissions
|
||||
# Works on Steam Deck and other Linux systems without sudo
|
||||
|
||||
echo "========================================="
|
||||
echo " Nebula Browser Setup Script"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
|
||||
# Navigate to the project directory
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# Run npm install
|
||||
echo "[1/2] Installing dependencies..."
|
||||
npm install
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ npm install failed!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "[2/2] Fixing Electron sandbox permissions..."
|
||||
echo "This requires root access. You may be prompted for your password."
|
||||
echo ""
|
||||
|
||||
# Fix chrome-sandbox permissions
|
||||
SANDBOX_PATH="$(pwd)/node_modules/electron/dist/chrome-sandbox"
|
||||
|
||||
if [ ! -f "$SANDBOX_PATH" ]; then
|
||||
echo "❌ chrome-sandbox not found at $SANDBOX_PATH"
|
||||
echo " Make sure npm install completed successfully."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Function to run command as root
|
||||
run_as_root() {
|
||||
if command -v sudo &> /dev/null; then
|
||||
sudo "$@"
|
||||
elif command -v pkexec &> /dev/null; then
|
||||
pkexec "$@"
|
||||
elif command -v doas &> /dev/null; then
|
||||
doas "$@"
|
||||
else
|
||||
echo "No privilege escalation tool found (sudo/pkexec/doas)"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Try to fix permissions
|
||||
echo "Attempting to set sandbox permissions..."
|
||||
run_as_root chown root:root "$SANDBOX_PATH"
|
||||
CHOWN_RESULT=$?
|
||||
|
||||
run_as_root chmod 4755 "$SANDBOX_PATH"
|
||||
CHMOD_RESULT=$?
|
||||
|
||||
if [ $CHOWN_RESULT -eq 0 ] && [ $CHMOD_RESULT -eq 0 ]; then
|
||||
echo "✅ Sandbox permissions fixed successfully!"
|
||||
echo ""
|
||||
echo "========================================="
|
||||
echo " Setup complete! Run 'npm start' to launch Nebula"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
echo "💡 TIP: For GPU acceleration on Linux, run:"
|
||||
echo " NEBULA_GPU_ALLOW_LINUX=1 npm start"
|
||||
echo "========================================="
|
||||
else
|
||||
echo "❌ Failed to set sandbox permissions automatically."
|
||||
echo ""
|
||||
echo "On Steam Deck, open Konsole and run:"
|
||||
echo " pkexec bash -c 'chown root:root $SANDBOX_PATH && chmod 4755 $SANDBOX_PATH'"
|
||||
echo ""
|
||||
echo "Or switch to desktop mode and run as root:"
|
||||
echo " su -c 'chown root:root $SANDBOX_PATH && chmod 4755 $SANDBOX_PATH'"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,740 @@
|
||||
#include "app/nebula_controller.h"
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <charconv>
|
||||
#include <cctype>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <system_error>
|
||||
|
||||
#include "browser/session_state.h"
|
||||
#include "browser/url_utils.h"
|
||||
#include "include/cef_app.h"
|
||||
#include "include/cef_browser.h"
|
||||
#include "include/cef_cookie.h"
|
||||
#include "include/wrapper/cef_helpers.h"
|
||||
#include "ui/paths.h"
|
||||
|
||||
namespace nebula::app {
|
||||
namespace {
|
||||
|
||||
constexpr size_t kMaxSiteHistoryEntries = 200;
|
||||
|
||||
std::wstring Utf8ToWide(const std::string& value) {
|
||||
if (value.empty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const int size = MultiByteToWideChar(
|
||||
CP_UTF8, 0, value.data(), static_cast<int>(value.size()), nullptr, 0);
|
||||
std::wstring result(size, L'\0');
|
||||
MultiByteToWideChar(
|
||||
CP_UTF8, 0, value.data(), static_cast<int>(value.size()), result.data(), size);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::filesystem::path GetSiteHistoryPath() {
|
||||
const auto user_data = nebula::ui::GetUserDataDirectory();
|
||||
return user_data.empty() ? std::filesystem::path{} : user_data / L"site_history.txt";
|
||||
}
|
||||
|
||||
std::string ToLowerAscii(std::string value) {
|
||||
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char ch) {
|
||||
return static_cast<char>(std::tolower(ch));
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
bool IsSiteHistoryUrl(const std::string& url) {
|
||||
const std::string lower = ToLowerAscii(url);
|
||||
return lower.starts_with("http://") || lower.starts_with("https://");
|
||||
}
|
||||
|
||||
std::vector<std::string> LoadSiteHistory() {
|
||||
std::vector<std::string> history;
|
||||
std::ifstream input(GetSiteHistoryPath(), std::ios::binary);
|
||||
if (!input) {
|
||||
return history;
|
||||
}
|
||||
|
||||
std::string url;
|
||||
while (std::getline(input, url) && history.size() < kMaxSiteHistoryEntries) {
|
||||
if (IsSiteHistoryUrl(url)) {
|
||||
history.push_back(url);
|
||||
}
|
||||
}
|
||||
return history;
|
||||
}
|
||||
|
||||
void SaveSiteHistory(const std::vector<std::string>& history) {
|
||||
const auto path = GetSiteHistoryPath();
|
||||
if (path.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::ofstream output(path, std::ios::binary | std::ios::trunc);
|
||||
if (!output) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const auto& url : history) {
|
||||
output << url << '\n';
|
||||
}
|
||||
}
|
||||
|
||||
std::string SiteHistoryJson(const std::vector<std::string>& history) {
|
||||
std::string json = "[";
|
||||
for (size_t i = 0; i < history.size(); ++i) {
|
||||
if (i > 0) {
|
||||
json += ",";
|
||||
}
|
||||
json += "\"" + nebula::browser::JsonEscape(history[i]) + "\"";
|
||||
}
|
||||
json += "]";
|
||||
return json;
|
||||
}
|
||||
|
||||
CefWindowInfo ChildWindowInfo(HWND parent, const RECT& rect) {
|
||||
CefWindowInfo info;
|
||||
info.SetAsChild(
|
||||
parent,
|
||||
CefRect(
|
||||
rect.left,
|
||||
rect.top,
|
||||
rect.right - rect.left,
|
||||
rect.bottom - rect.top));
|
||||
// CEF defaults to the Chrome runtime style, which ignores the
|
||||
// SetAsChild hint and creates a top-level window per browser. Force the
|
||||
// Alloy runtime style so each browser embeds inside the Nebula HWND.
|
||||
info.runtime_style = CEF_RUNTIME_STYLE_ALLOY;
|
||||
return info;
|
||||
}
|
||||
|
||||
CefBrowserSettings BrowserSettings() {
|
||||
CefBrowserSettings settings;
|
||||
settings.webgl = STATE_ENABLED;
|
||||
return settings;
|
||||
}
|
||||
|
||||
int ParseTabId(const std::string& value) {
|
||||
int tab_id = 0;
|
||||
const auto result = std::from_chars(value.data(), value.data() + value.size(), tab_id);
|
||||
return result.ec == std::errc{} && result.ptr == value.data() + value.size() ? tab_id : 0;
|
||||
}
|
||||
|
||||
int ScaleForWindow(HWND hwnd, int value) {
|
||||
return MulDiv(value, static_cast<int>(GetDpiForWindow(hwnd)), 96);
|
||||
}
|
||||
|
||||
RECT MenuPopupRect(HWND hwnd, const nebula::window::BrowserLayout& layout) {
|
||||
RECT client = {};
|
||||
GetClientRect(hwnd, &client);
|
||||
|
||||
const int width = ScaleForWindow(hwnd, 260);
|
||||
const int height = ScaleForWindow(hwnd, 258);
|
||||
const int margin = ScaleForWindow(hwnd, 12);
|
||||
const int overlap = ScaleForWindow(hwnd, 2);
|
||||
|
||||
const LONG x = std::max<LONG>(margin, client.right - width - margin);
|
||||
const LONG y = std::max<LONG>(0, layout.chrome.bottom - overlap);
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
std::min<LONG>(client.right, x + width),
|
||||
std::min<LONG>(client.bottom, y + height),
|
||||
};
|
||||
}
|
||||
|
||||
void ApplyRoundedWindowRegion(HWND hwnd, int corner_radius) {
|
||||
RECT rect = {};
|
||||
if (!hwnd || !GetClientRect(hwnd, &rect)) {
|
||||
return;
|
||||
}
|
||||
|
||||
HRGN region = CreateRoundRectRgn(
|
||||
0,
|
||||
0,
|
||||
std::max<LONG>(1, rect.right - rect.left) + 1,
|
||||
std::max<LONG>(1, rect.bottom - rect.top) + 1,
|
||||
corner_radius,
|
||||
corner_radius);
|
||||
if (region && !SetWindowRgn(hwnd, region, TRUE)) {
|
||||
DeleteObject(region);
|
||||
}
|
||||
}
|
||||
|
||||
std::string WithCacheBuster(std::string url) {
|
||||
if (url.empty()) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const size_t hash = url.find('#');
|
||||
std::string fragment;
|
||||
if (hash != std::string::npos) {
|
||||
fragment = url.substr(hash);
|
||||
url.resize(hash);
|
||||
}
|
||||
|
||||
const char separator = url.find('?') == std::string::npos ? '?' : '&';
|
||||
return url + separator + "nebula_cache_bust=" + std::to_string(GetTickCount64()) + fragment;
|
||||
}
|
||||
|
||||
std::string GetChromeDisplayUrl(const std::string& url) {
|
||||
return nebula::ui::IsInternalHomeUrl(url) ? std::string{} : url;
|
||||
}
|
||||
|
||||
void SetBrowserVisible(CefRefPtr<CefBrowser> browser, bool visible) {
|
||||
if (!browser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const HWND hwnd = browser->GetHost()->GetWindowHandle();
|
||||
if (!hwnd) {
|
||||
return;
|
||||
}
|
||||
|
||||
ShowWindow(hwnd, visible ? SW_SHOW : SW_HIDE);
|
||||
if (visible) {
|
||||
SetWindowPos(hwnd, HWND_TOP, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
NebulaController::NebulaController(HINSTANCE instance, std::string initial_url, int show_command)
|
||||
: instance_(instance),
|
||||
initial_url_(std::move(initial_url)),
|
||||
show_command_(show_command),
|
||||
tabs_(this),
|
||||
site_history_(LoadSiteHistory()) {}
|
||||
|
||||
NebulaController::~NebulaController() = default;
|
||||
|
||||
bool NebulaController::Create() {
|
||||
window_ = std::make_unique<nebula::window::NebulaWindow>(this);
|
||||
return window_->Create(instance_, show_command_);
|
||||
}
|
||||
|
||||
void NebulaController::OnWindowCreated() {
|
||||
if (initial_url_.empty()) {
|
||||
tabs_.CreateInitialTab(nebula::ui::GetHomeUrl());
|
||||
} else {
|
||||
tabs_.CreateInitialTab(initial_url_);
|
||||
}
|
||||
PersistSession();
|
||||
|
||||
CreateChromeBrowser();
|
||||
CreateContentBrowser();
|
||||
}
|
||||
|
||||
void NebulaController::OnWindowResized(const nebula::window::BrowserLayout& layout) {
|
||||
UNREFERENCED_PARAMETER(layout);
|
||||
ResizeBrowsers();
|
||||
}
|
||||
|
||||
void NebulaController::OnWindowCloseRequested() {
|
||||
if (closing_) {
|
||||
// CEF re-sends WM_CLOSE to the top-level window after each Alloy
|
||||
// child browser finishes its JS unload + DoClose phase. Destroy the
|
||||
// Nebula window now so CEF can tear down the child browser HWNDs and
|
||||
// fire OnBeforeClose; MaybeFinishShutdown will then quit the loop.
|
||||
if (window_ && window_->hwnd()) {
|
||||
DestroyWindow(window_->hwnd());
|
||||
}
|
||||
MaybeFinishShutdown();
|
||||
return;
|
||||
}
|
||||
|
||||
closing_ = true;
|
||||
PersistSession();
|
||||
if (auto cookie_manager = CefCookieManager::GetGlobalManager(nullptr)) {
|
||||
cookie_manager->FlushStore(nullptr);
|
||||
}
|
||||
|
||||
if (chrome_browser_) {
|
||||
chrome_browser_->GetHost()->CloseBrowser(false);
|
||||
}
|
||||
if (menu_popup_browser_) {
|
||||
menu_popup_browser_->GetHost()->CloseBrowser(false);
|
||||
}
|
||||
for (const auto& tab : tabs_.Tabs()) {
|
||||
if (tab.browser) {
|
||||
tab.browser->GetHost()->CloseBrowser(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void NebulaController::OnActiveTabChanged(const nebula::browser::NebulaTab& tab) {
|
||||
if (chrome_ready_) {
|
||||
SendChromeState(tab);
|
||||
}
|
||||
}
|
||||
|
||||
void NebulaController::OnBrowserCreated(nebula::cef::BrowserRole role, CefRefPtr<CefBrowser> browser) {
|
||||
if (window_ && browser) {
|
||||
window_->EnableFrameHitTest(browser->GetHost()->GetWindowHandle());
|
||||
}
|
||||
|
||||
if (role == nebula::cef::BrowserRole::Chrome) {
|
||||
chrome_browser_ = browser;
|
||||
chrome_ready_ = true;
|
||||
if (const auto* tab = tabs_.ActiveTab()) {
|
||||
SendChromeState(*tab);
|
||||
}
|
||||
} else if (role == nebula::cef::BrowserRole::MenuPopup) {
|
||||
menu_popup_browser_ = browser;
|
||||
PositionMenuPopup();
|
||||
} else {
|
||||
tabs_.SetActiveBrowser(browser);
|
||||
}
|
||||
|
||||
ResizeBrowsers();
|
||||
}
|
||||
|
||||
void NebulaController::OnBrowserClosing(nebula::cef::BrowserRole role, CefRefPtr<CefBrowser> browser) {
|
||||
if (role == nebula::cef::BrowserRole::Chrome) {
|
||||
chrome_browser_ = nullptr;
|
||||
chrome_ready_ = false;
|
||||
} else if (role == nebula::cef::BrowserRole::MenuPopup) {
|
||||
menu_popup_browser_ = nullptr;
|
||||
menu_popup_client_ = nullptr;
|
||||
} else {
|
||||
if (content_fullscreen_) {
|
||||
const auto* active_tab = tabs_.ActiveTab();
|
||||
if (active_tab && active_tab->browser && active_tab->browser->IsSame(browser)) {
|
||||
SetContentFullscreen(false);
|
||||
}
|
||||
}
|
||||
tabs_.ClearBrowser(browser);
|
||||
}
|
||||
MaybeFinishShutdown();
|
||||
}
|
||||
|
||||
void NebulaController::OnChromeCommand(const std::string& command, const std::string& payload) {
|
||||
if (command == "navigate") {
|
||||
tabs_.LoadURL(payload);
|
||||
} else if (command == "navigate-insecure") {
|
||||
const std::string target = nebula::browser::NormalizeNavigationInput(payload);
|
||||
if (nebula::ui::IsHttpUrl(target)) {
|
||||
insecure_warning_bypasses_.insert(target);
|
||||
tabs_.LoadURL(target);
|
||||
}
|
||||
} else if (command == "new-tab") {
|
||||
CreateNewTab();
|
||||
} else if (command == "activate-tab") {
|
||||
ActivateTab(ParseTabId(payload));
|
||||
} else if (command == "close-tab") {
|
||||
CloseTab(ParseTabId(payload));
|
||||
} else if (command == "back") {
|
||||
tabs_.GoBack();
|
||||
} else if (command == "forward") {
|
||||
tabs_.GoForward();
|
||||
} else if (command == "reload") {
|
||||
tabs_.Reload();
|
||||
} else if (command == "stop") {
|
||||
tabs_.StopLoad();
|
||||
} else if (command == "settings") {
|
||||
tabs_.LoadURL(nebula::ui::GetSettingsUrl());
|
||||
} else if (command == "menu-popup") {
|
||||
ToggleMenuPopup();
|
||||
} else if (command == "open-settings") {
|
||||
CloseMenuPopup();
|
||||
tabs_.LoadURL(nebula::ui::GetSettingsUrl());
|
||||
} else if (command == "big-picture") {
|
||||
CloseMenuPopup();
|
||||
tabs_.LoadURL(nebula::ui::GetBigPictureUrl());
|
||||
} else if (command == "gpu-diagnostics") {
|
||||
CloseMenuPopup();
|
||||
tabs_.LoadURL(nebula::ui::GetGpuDiagnosticsUrl());
|
||||
} else if (command == "toggle-devtools") {
|
||||
ToggleDevTools();
|
||||
} else if (command == "zoom-out") {
|
||||
AdjustZoom(-0.5);
|
||||
} else if (command == "zoom-in") {
|
||||
AdjustZoom(0.5);
|
||||
} else if (command == "hard-reload") {
|
||||
CloseMenuPopup();
|
||||
if (auto* tab = tabs_.ActiveTab(); tab && tab->browser) {
|
||||
tab->browser->ReloadIgnoreCache();
|
||||
}
|
||||
} else if (command == "fresh-reload") {
|
||||
CloseMenuPopup();
|
||||
FreshReload();
|
||||
} else if (command == "close-menu-popup") {
|
||||
CloseMenuPopup();
|
||||
} else if (command == "home") {
|
||||
tabs_.LoadURL(nebula::ui::GetHomeUrl());
|
||||
} else if (command == "clear-site-history") {
|
||||
site_history_.clear();
|
||||
SaveSiteHistory(site_history_);
|
||||
if (auto* tab = tabs_.ActiveTab(); tab && tab->browser) {
|
||||
InjectSettingsHistory(tab->browser);
|
||||
}
|
||||
} else if (command == "minimize" && window_) {
|
||||
window_->Minimize();
|
||||
} else if (command == "maximize" && window_) {
|
||||
window_->ToggleMaximize();
|
||||
} else if (command == "close" && window_) {
|
||||
window_->Close();
|
||||
} else if (command == "drag" && window_) {
|
||||
window_->BeginDrag();
|
||||
}
|
||||
}
|
||||
|
||||
void NebulaController::OnContentAddressChanged(CefRefPtr<CefBrowser> browser, const std::string& url) {
|
||||
const std::string internal_url = nebula::ui::ToInternalUrl(url);
|
||||
tabs_.UpdateURL(browser,
|
||||
nebula::ui::IsChromiumNewTabUrl(url)
|
||||
? nebula::ui::GetHomeUrl()
|
||||
: internal_url);
|
||||
RecordSiteHistory(internal_url);
|
||||
PersistSession();
|
||||
}
|
||||
|
||||
void NebulaController::OnContentTitleChanged(CefRefPtr<CefBrowser> browser, const std::string& title) {
|
||||
tabs_.UpdateTitle(browser, title);
|
||||
PersistSession();
|
||||
const auto* active_tab = tabs_.ActiveTab();
|
||||
if (window_ && active_tab && active_tab->browser && active_tab->browser->IsSame(browser)) {
|
||||
window_->SetTitle(Utf8ToWide(title.empty() ? "Nebula Browser" : title + " - Nebula"));
|
||||
}
|
||||
}
|
||||
|
||||
void NebulaController::OnContentLoadingStateChanged(CefRefPtr<CefBrowser> browser, bool is_loading) {
|
||||
tabs_.UpdateLoadingState(browser, is_loading);
|
||||
}
|
||||
|
||||
void NebulaController::OnContentLoadProgressChanged(CefRefPtr<CefBrowser> browser, double progress) {
|
||||
tabs_.UpdateLoadProgress(browser, progress);
|
||||
}
|
||||
|
||||
void NebulaController::OnContentLoadFinished(CefRefPtr<CefBrowser> browser, const std::string& url) {
|
||||
if (nebula::ui::ToInternalUrl(url).starts_with(nebula::ui::GetSettingsUrl())) {
|
||||
InjectSettingsHistory(browser);
|
||||
}
|
||||
}
|
||||
|
||||
void NebulaController::OnContentFaviconChanged(CefRefPtr<CefBrowser> browser, const std::vector<std::string>& urls) {
|
||||
tabs_.UpdateFavicon(browser, urls);
|
||||
}
|
||||
|
||||
void NebulaController::OnContentFullscreenChanged(CefRefPtr<CefBrowser> browser, bool fullscreen) {
|
||||
const auto* active_tab = tabs_.ActiveTab();
|
||||
if (!active_tab || !active_tab->browser || !active_tab->browser->IsSame(browser)) {
|
||||
return;
|
||||
}
|
||||
|
||||
SetContentFullscreen(fullscreen);
|
||||
}
|
||||
|
||||
void NebulaController::OnPopupRequested(CefRefPtr<CefBrowser> browser, const std::string& target_url) {
|
||||
if (!tabs_.OwnsBrowser(browser)) {
|
||||
return;
|
||||
}
|
||||
|
||||
tabs_.LoadURL(nebula::ui::IsEmptyOrChromiumNewTabUrl(target_url)
|
||||
? nebula::ui::GetHomeUrl()
|
||||
: target_url);
|
||||
}
|
||||
|
||||
bool NebulaController::ShouldBypassInsecureWarning(CefRefPtr<CefBrowser> browser, const std::string& target_url) {
|
||||
if (!tabs_.OwnsBrowser(browser)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto bypass = insecure_warning_bypasses_.find(target_url);
|
||||
if (bypass == insecure_warning_bypasses_.end()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
insecure_warning_bypasses_.erase(bypass);
|
||||
return true;
|
||||
}
|
||||
|
||||
void NebulaController::CreateNewTab() {
|
||||
if (auto* tab = tabs_.ActiveTab()) {
|
||||
SetBrowserVisible(tab->browser, false);
|
||||
}
|
||||
|
||||
tabs_.CreateTab(nebula::ui::GetHomeUrl());
|
||||
PersistSession();
|
||||
CreateContentBrowser();
|
||||
}
|
||||
|
||||
void NebulaController::ActivateTab(int tab_id) {
|
||||
auto* current_tab = tabs_.ActiveTab();
|
||||
if (current_tab && current_tab->id == tab_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
CefRefPtr<CefBrowser> previous_browser = current_tab ? current_tab->browser : nullptr;
|
||||
if (!tabs_.ActivateTab(tab_id)) {
|
||||
return;
|
||||
}
|
||||
PersistSession();
|
||||
|
||||
SetBrowserVisible(previous_browser, false);
|
||||
if (auto* active_tab = tabs_.ActiveTab()) {
|
||||
if (active_tab->browser) {
|
||||
SetBrowserVisible(active_tab->browser, true);
|
||||
} else {
|
||||
CreateContentBrowser();
|
||||
}
|
||||
}
|
||||
ResizeBrowsers();
|
||||
}
|
||||
|
||||
void NebulaController::CloseTab(int tab_id) {
|
||||
const bool was_active = [this, tab_id] {
|
||||
const auto* tab = tabs_.ActiveTab();
|
||||
return tab && tab->id == tab_id;
|
||||
}();
|
||||
|
||||
CefRefPtr<CefBrowser> closing_browser = tabs_.CloseTab(tab_id);
|
||||
PersistSession();
|
||||
if (closing_browser) {
|
||||
closing_browser->GetHost()->CloseBrowser(false);
|
||||
}
|
||||
|
||||
if (!tabs_.ActiveTab()) {
|
||||
tabs_.CreateTab(nebula::ui::GetHomeUrl());
|
||||
PersistSession();
|
||||
CreateContentBrowser();
|
||||
return;
|
||||
}
|
||||
|
||||
if (was_active) {
|
||||
if (auto* active_tab = tabs_.ActiveTab()) {
|
||||
SetBrowserVisible(active_tab->browser, true);
|
||||
}
|
||||
ResizeBrowsers();
|
||||
}
|
||||
}
|
||||
|
||||
void NebulaController::CreateChromeBrowser() {
|
||||
if (!window_ || !window_->hwnd()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto layout = window_->CurrentLayout();
|
||||
CefBrowserSettings browser_settings = BrowserSettings();
|
||||
chrome_client_ = new nebula::cef::NebulaBrowserClient(nebula::cef::BrowserRole::Chrome, this);
|
||||
CefWindowInfo window_info = ChildWindowInfo(window_->hwnd(), layout.chrome);
|
||||
CefBrowserHost::CreateBrowser(
|
||||
window_info, chrome_client_, nebula::ui::GetChromeUrl(), browser_settings, nullptr, nullptr);
|
||||
}
|
||||
|
||||
void NebulaController::CreateContentBrowser() {
|
||||
if (!window_ || !window_->hwnd()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto* tab = tabs_.ActiveTab();
|
||||
const std::string url = tab && !tab->url.empty() ? tab->url : nebula::ui::GetHomeUrl();
|
||||
const auto layout = window_->CurrentLayout();
|
||||
CefBrowserSettings browser_settings = BrowserSettings();
|
||||
content_client_ = new nebula::cef::NebulaBrowserClient(nebula::cef::BrowserRole::Content, this);
|
||||
CefWindowInfo window_info = ChildWindowInfo(window_->hwnd(), layout.content);
|
||||
CefBrowserHost::CreateBrowser(
|
||||
window_info, content_client_, nebula::ui::ResolveInternalUrl(url), browser_settings, nullptr, nullptr);
|
||||
}
|
||||
|
||||
void NebulaController::ToggleMenuPopup() {
|
||||
if (menu_popup_browser_) {
|
||||
CloseMenuPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
CreateMenuPopupBrowser();
|
||||
}
|
||||
|
||||
void NebulaController::CloseMenuPopup() {
|
||||
if (menu_popup_browser_) {
|
||||
menu_popup_browser_->GetHost()->CloseBrowser(false);
|
||||
}
|
||||
}
|
||||
|
||||
void NebulaController::CreateMenuPopupBrowser() {
|
||||
if (!window_ || !window_->hwnd()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto layout = window_->CurrentLayout();
|
||||
CefBrowserSettings browser_settings = BrowserSettings();
|
||||
menu_popup_client_ = new nebula::cef::NebulaBrowserClient(nebula::cef::BrowserRole::MenuPopup, this);
|
||||
CefWindowInfo window_info = ChildWindowInfo(window_->hwnd(), MenuPopupRect(window_->hwnd(), layout));
|
||||
CefBrowserHost::CreateBrowser(
|
||||
window_info, menu_popup_client_, nebula::ui::GetMenuPopupUrl(), browser_settings, nullptr, nullptr);
|
||||
}
|
||||
|
||||
void NebulaController::PositionMenuPopup() {
|
||||
if (content_fullscreen_ || !window_ || !window_->hwnd() || !menu_popup_browser_) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto rect = MenuPopupRect(window_->hwnd(), window_->CurrentLayout());
|
||||
const HWND hwnd = menu_popup_browser_->GetHost()->GetWindowHandle();
|
||||
window_->ResizeChild(hwnd, rect);
|
||||
ApplyRoundedWindowRegion(hwnd, ScaleForWindow(window_->hwnd(), 28));
|
||||
SetWindowPos(hwnd, HWND_TOP, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
|
||||
}
|
||||
|
||||
void NebulaController::ToggleDevTools() {
|
||||
auto* tab = tabs_.ActiveTab();
|
||||
if (!tab || !tab->browser || !window_ || !window_->hwnd()) {
|
||||
return;
|
||||
}
|
||||
|
||||
CefRefPtr<CefBrowserHost> host = tab->browser->GetHost();
|
||||
if (host->HasDevTools()) {
|
||||
host->CloseDevTools();
|
||||
return;
|
||||
}
|
||||
|
||||
CefWindowInfo window_info;
|
||||
window_info.SetAsPopup(window_->hwnd(), "Nebula Developer Tools");
|
||||
window_info.runtime_style = CEF_RUNTIME_STYLE_ALLOY;
|
||||
CefBrowserSettings browser_settings;
|
||||
host->ShowDevTools(window_info, content_client_, browser_settings, CefPoint());
|
||||
}
|
||||
|
||||
void NebulaController::AdjustZoom(double delta) {
|
||||
auto* tab = tabs_.ActiveTab();
|
||||
if (!tab || !tab->browser) {
|
||||
return;
|
||||
}
|
||||
|
||||
CefRefPtr<CefBrowserHost> host = tab->browser->GetHost();
|
||||
host->SetZoomLevel(host->GetZoomLevel() + delta);
|
||||
}
|
||||
|
||||
void NebulaController::FreshReload() {
|
||||
auto* tab = tabs_.ActiveTab();
|
||||
if (!tab || tab->url.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
tabs_.LoadURL(WithCacheBuster(tab->url));
|
||||
}
|
||||
|
||||
void NebulaController::SetContentFullscreen(bool fullscreen) {
|
||||
if (content_fullscreen_ == fullscreen) {
|
||||
return;
|
||||
}
|
||||
|
||||
content_fullscreen_ = fullscreen;
|
||||
if (fullscreen) {
|
||||
CloseMenuPopup();
|
||||
}
|
||||
|
||||
SetBrowserVisible(chrome_browser_, !fullscreen);
|
||||
if (window_) {
|
||||
window_->SetFullscreen(fullscreen);
|
||||
}
|
||||
ResizeBrowsers();
|
||||
}
|
||||
|
||||
void NebulaController::ResizeBrowsers() {
|
||||
if (!window_) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto layout = window_->CurrentLayout(!content_fullscreen_);
|
||||
if (chrome_browser_) {
|
||||
window_->ResizeChild(chrome_browser_->GetHost()->GetWindowHandle(), layout.chrome);
|
||||
}
|
||||
if (const auto* tab = tabs_.ActiveTab(); tab && tab->browser) {
|
||||
window_->ResizeChild(tab->browser->GetHost()->GetWindowHandle(), layout.content);
|
||||
}
|
||||
if (!content_fullscreen_) {
|
||||
PositionMenuPopup();
|
||||
}
|
||||
}
|
||||
|
||||
void NebulaController::SendChromeState(const nebula::browser::NebulaTab& tab) {
|
||||
if (!chrome_browser_) {
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string display_url = GetChromeDisplayUrl(tab.url);
|
||||
std::string tabs_json = "[";
|
||||
const auto& tabs = tabs_.Tabs();
|
||||
for (size_t i = 0; i < tabs.size(); ++i) {
|
||||
const auto& item = tabs[i];
|
||||
if (i > 0) {
|
||||
tabs_json += ",";
|
||||
}
|
||||
tabs_json +=
|
||||
"{\"id\":" + std::to_string(item.id) +
|
||||
",\"title\":\"" + nebula::browser::JsonEscape(item.title) + "\"" +
|
||||
",\"isLoading\":" + std::string(item.is_loading ? "true" : "false") +
|
||||
",\"favicon\":\"" + nebula::browser::JsonEscape(item.favicon_url) + "\"" +
|
||||
"}";
|
||||
}
|
||||
tabs_json += "]";
|
||||
|
||||
const std::string script =
|
||||
"window.NebulaChrome && window.NebulaChrome.applyState({"
|
||||
"\"id\":" + std::to_string(tab.id) +
|
||||
",\"url\":\"" + nebula::browser::JsonEscape(display_url) + "\""
|
||||
",\"title\":\"" + nebula::browser::JsonEscape(tab.title) + "\""
|
||||
",\"isLoading\":" + std::string(tab.is_loading ? "true" : "false") +
|
||||
",\"progress\":" + std::to_string(tab.load_progress) +
|
||||
",\"canGoBack\":" + std::string(tab.CanGoBack() ? "true" : "false") +
|
||||
",\"canGoForward\":" + std::string(tab.CanGoForward() ? "true" : "false") +
|
||||
",\"favicon\":\"" + nebula::browser::JsonEscape(tab.favicon_url) + "\"" +
|
||||
",\"tabs\":" + tabs_json +
|
||||
"});";
|
||||
|
||||
chrome_browser_->GetMainFrame()->ExecuteJavaScript(script, nebula::ui::GetChromeUrl(), 0);
|
||||
}
|
||||
|
||||
void NebulaController::RecordSiteHistory(const std::string& url) {
|
||||
if (!IsSiteHistoryUrl(url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
site_history_.erase(
|
||||
std::remove(site_history_.begin(), site_history_.end(), url),
|
||||
site_history_.end());
|
||||
site_history_.insert(site_history_.begin(), url);
|
||||
if (site_history_.size() > kMaxSiteHistoryEntries) {
|
||||
site_history_.resize(kMaxSiteHistoryEntries);
|
||||
}
|
||||
SaveSiteHistory(site_history_);
|
||||
}
|
||||
|
||||
void NebulaController::InjectSettingsHistory(CefRefPtr<CefBrowser> browser) {
|
||||
if (!browser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string history_json = SiteHistoryJson(site_history_);
|
||||
const std::string script =
|
||||
"localStorage.setItem('siteHistory', \"" + nebula::browser::JsonEscape(history_json) + "\");"
|
||||
"if (typeof loadHistories === 'function') { loadHistories(); }";
|
||||
browser->GetMainFrame()->ExecuteJavaScript(script, nebula::ui::GetSettingsUrl(), 0);
|
||||
}
|
||||
|
||||
void NebulaController::PersistSession() const {
|
||||
nebula::browser::SaveSessionState(tabs_.Tabs(), tabs_.ActiveTabIndex());
|
||||
}
|
||||
|
||||
void NebulaController::MaybeFinishShutdown() {
|
||||
if (!closing_) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (chrome_browser_ || menu_popup_browser_ || tabs_.HasOpenBrowsers()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window_ && window_->hwnd()) {
|
||||
DestroyWindow(window_->hwnd());
|
||||
}
|
||||
CefQuitMessageLoop();
|
||||
}
|
||||
|
||||
} // namespace nebula::app
|
||||
@@ -0,0 +1,81 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
#include "browser/tab_manager.h"
|
||||
#include "cef/browser_client.h"
|
||||
#include "window/nebula_window.h"
|
||||
|
||||
namespace nebula::app {
|
||||
|
||||
class NebulaController final : public nebula::window::WindowDelegate,
|
||||
public nebula::browser::TabObserver,
|
||||
public nebula::cef::BrowserClientDelegate {
|
||||
public:
|
||||
NebulaController(HINSTANCE instance, std::string initial_url, int show_command);
|
||||
~NebulaController() override;
|
||||
|
||||
bool Create();
|
||||
|
||||
void OnWindowCreated() override;
|
||||
void OnWindowResized(const nebula::window::BrowserLayout& layout) override;
|
||||
void OnWindowCloseRequested() override;
|
||||
|
||||
void OnActiveTabChanged(const nebula::browser::NebulaTab& tab) override;
|
||||
|
||||
void OnBrowserCreated(nebula::cef::BrowserRole role, CefRefPtr<CefBrowser> browser) override;
|
||||
void OnBrowserClosing(nebula::cef::BrowserRole role, CefRefPtr<CefBrowser> browser) override;
|
||||
void OnChromeCommand(const std::string& command, const std::string& payload) override;
|
||||
void OnContentAddressChanged(CefRefPtr<CefBrowser> browser, const std::string& url) override;
|
||||
void OnContentTitleChanged(CefRefPtr<CefBrowser> browser, const std::string& title) override;
|
||||
void OnContentLoadingStateChanged(CefRefPtr<CefBrowser> browser, bool is_loading) override;
|
||||
void OnContentLoadProgressChanged(CefRefPtr<CefBrowser> browser, double progress) override;
|
||||
void OnContentLoadFinished(CefRefPtr<CefBrowser> browser, const std::string& url) override;
|
||||
void OnContentFaviconChanged(CefRefPtr<CefBrowser> browser, const std::vector<std::string>& urls) override;
|
||||
void OnContentFullscreenChanged(CefRefPtr<CefBrowser> browser, bool fullscreen) override;
|
||||
void OnPopupRequested(CefRefPtr<CefBrowser> browser, const std::string& target_url) override;
|
||||
bool ShouldBypassInsecureWarning(CefRefPtr<CefBrowser> browser, const std::string& target_url) override;
|
||||
|
||||
private:
|
||||
void CreateNewTab();
|
||||
void ActivateTab(int tab_id);
|
||||
void CloseTab(int tab_id);
|
||||
void CreateChromeBrowser();
|
||||
void CreateContentBrowser();
|
||||
void ToggleMenuPopup();
|
||||
void CloseMenuPopup();
|
||||
void CreateMenuPopupBrowser();
|
||||
void PositionMenuPopup();
|
||||
void ToggleDevTools();
|
||||
void AdjustZoom(double delta);
|
||||
void FreshReload();
|
||||
void SetContentFullscreen(bool fullscreen);
|
||||
void ResizeBrowsers();
|
||||
void SendChromeState(const nebula::browser::NebulaTab& tab);
|
||||
void RecordSiteHistory(const std::string& url);
|
||||
void InjectSettingsHistory(CefRefPtr<CefBrowser> browser);
|
||||
void PersistSession() const;
|
||||
void MaybeFinishShutdown();
|
||||
|
||||
HINSTANCE instance_ = nullptr;
|
||||
std::string initial_url_;
|
||||
int show_command_ = SW_SHOWDEFAULT;
|
||||
bool closing_ = false;
|
||||
bool chrome_ready_ = false;
|
||||
bool content_fullscreen_ = false;
|
||||
|
||||
std::unique_ptr<nebula::window::NebulaWindow> window_;
|
||||
nebula::browser::TabManager tabs_;
|
||||
CefRefPtr<CefBrowser> chrome_browser_;
|
||||
CefRefPtr<CefBrowser> menu_popup_browser_;
|
||||
CefRefPtr<nebula::cef::NebulaBrowserClient> chrome_client_;
|
||||
CefRefPtr<nebula::cef::NebulaBrowserClient> content_client_;
|
||||
CefRefPtr<nebula::cef::NebulaBrowserClient> menu_popup_client_;
|
||||
std::unordered_set<std::string> insecure_warning_bypasses_;
|
||||
std::vector<std::string> site_history_;
|
||||
};
|
||||
|
||||
} // namespace nebula::app
|
||||
@@ -0,0 +1,93 @@
|
||||
#include "app/run.h"
|
||||
|
||||
#include "app/nebula_controller.h"
|
||||
#include "cef/nebula_app.h"
|
||||
#include "include/cef_app.h"
|
||||
#include "include/cef_command_line.h"
|
||||
#include "ui/paths.h"
|
||||
|
||||
namespace nebula::app {
|
||||
namespace {
|
||||
|
||||
constexpr wchar_t kMainInstanceMutexName[] = L"Local\\NebulaBrowserMainInstance";
|
||||
|
||||
void EnableDpiAwareness() {
|
||||
SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
|
||||
}
|
||||
|
||||
class ScopedHandle {
|
||||
public:
|
||||
explicit ScopedHandle(HANDLE handle) : handle_(handle) {}
|
||||
~ScopedHandle() {
|
||||
if (handle_) {
|
||||
CloseHandle(handle_);
|
||||
}
|
||||
}
|
||||
|
||||
ScopedHandle(const ScopedHandle&) = delete;
|
||||
ScopedHandle& operator=(const ScopedHandle&) = delete;
|
||||
|
||||
bool valid() const { return handle_ != nullptr; }
|
||||
|
||||
private:
|
||||
HANDLE handle_ = nullptr;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
int RunNebula(HINSTANCE instance, int show_command) {
|
||||
EnableDpiAwareness();
|
||||
|
||||
CefMainArgs main_args(instance);
|
||||
CefRefPtr<nebula::cef::NebulaApp> app(new nebula::cef::NebulaApp);
|
||||
|
||||
const int subprocess_exit_code = CefExecuteProcess(main_args, app, nullptr);
|
||||
if (subprocess_exit_code >= 0) {
|
||||
return subprocess_exit_code;
|
||||
}
|
||||
|
||||
ScopedHandle main_instance_mutex(CreateMutexW(nullptr, TRUE, kMainInstanceMutexName));
|
||||
if (main_instance_mutex.valid() && GetLastError() == ERROR_ALREADY_EXISTS) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
CefSettings settings;
|
||||
settings.no_sandbox = true;
|
||||
settings.persist_session_cookies = true;
|
||||
|
||||
// A persistent profile is required for the GPU shader cache and several
|
||||
// hardware acceleration features. Without these Chromium silently falls
|
||||
// back to software rendering, which causes choppy video and disables
|
||||
// WebGL/WebGL2 in the GPU diagnostics page.
|
||||
const std::wstring user_data_dir = nebula::ui::GetUserDataDirectory().wstring();
|
||||
const std::wstring cache_dir = nebula::ui::GetCacheDirectory().wstring();
|
||||
if (!user_data_dir.empty()) {
|
||||
CefString(&settings.root_cache_path).FromWString(user_data_dir);
|
||||
}
|
||||
if (!cache_dir.empty()) {
|
||||
CefString(&settings.cache_path).FromWString(cache_dir);
|
||||
}
|
||||
|
||||
if (!CefInitialize(main_args, settings, app, nullptr)) {
|
||||
return CefGetExitCode();
|
||||
}
|
||||
|
||||
CefRefPtr<CefCommandLine> command_line = CefCommandLine::CreateCommandLine();
|
||||
command_line->InitFromString(GetCommandLineW());
|
||||
|
||||
std::string initial_url = command_line->GetSwitchValue("url");
|
||||
if (!initial_url.empty() && nebula::ui::IsChromiumNewTabUrl(initial_url)) {
|
||||
initial_url = nebula::ui::GetHomeUrl();
|
||||
}
|
||||
|
||||
NebulaController controller(instance, initial_url, show_command);
|
||||
const bool created = controller.Create();
|
||||
if (created) {
|
||||
CefRunMessageLoop();
|
||||
}
|
||||
|
||||
CefShutdown();
|
||||
return created ? 0 : 1;
|
||||
}
|
||||
|
||||
} // namespace nebula::app
|
||||
@@ -0,0 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
namespace nebula::app {
|
||||
|
||||
int RunNebula(HINSTANCE instance, int show_command);
|
||||
|
||||
} // namespace nebula::app
|
||||
@@ -0,0 +1,229 @@
|
||||
#include "browser/session_state.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <charconv>
|
||||
#include <cctype>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <system_error>
|
||||
|
||||
#include "browser/url_utils.h"
|
||||
#include "ui/paths.h"
|
||||
|
||||
namespace nebula::browser {
|
||||
namespace {
|
||||
|
||||
constexpr size_t kMaxRestoredTabs = 50;
|
||||
|
||||
std::string ReadFile(const std::filesystem::path& path) {
|
||||
std::ifstream input(path, std::ios::binary);
|
||||
if (!input) {
|
||||
return {};
|
||||
}
|
||||
|
||||
std::ostringstream buffer;
|
||||
buffer << input.rdbuf();
|
||||
return buffer.str();
|
||||
}
|
||||
|
||||
std::optional<size_t> ReadUnsignedValue(const std::string& json, std::string_view key) {
|
||||
const size_t key_pos = json.find(key);
|
||||
if (key_pos == std::string::npos) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
size_t colon = json.find(':', key_pos + key.size());
|
||||
if (colon == std::string::npos) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
++colon;
|
||||
while (colon < json.size() && std::isspace(static_cast<unsigned char>(json[colon]))) {
|
||||
++colon;
|
||||
}
|
||||
|
||||
size_t end = colon;
|
||||
while (end < json.size() && std::isdigit(static_cast<unsigned char>(json[end]))) {
|
||||
++end;
|
||||
}
|
||||
|
||||
size_t value = 0;
|
||||
const auto result = std::from_chars(json.data() + colon, json.data() + end, value);
|
||||
if (result.ec != std::errc{} || result.ptr != json.data() + end) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
std::optional<std::string> ReadStringValue(const std::string& object, std::string_view key) {
|
||||
const size_t key_pos = object.find(key);
|
||||
if (key_pos == std::string::npos) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
size_t colon = object.find(':', key_pos + key.size());
|
||||
if (colon == std::string::npos) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
size_t quote = object.find('"', colon + 1);
|
||||
if (quote == std::string::npos) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::string value;
|
||||
for (size_t i = quote + 1; i < object.size(); ++i) {
|
||||
const char ch = object[i];
|
||||
if (ch == '"') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (ch != '\\') {
|
||||
value += ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (++i >= object.size()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
switch (object[i]) {
|
||||
case '"':
|
||||
case '\\':
|
||||
case '/':
|
||||
value += object[i];
|
||||
break;
|
||||
case 'b':
|
||||
value += '\b';
|
||||
break;
|
||||
case 'f':
|
||||
value += '\f';
|
||||
break;
|
||||
case 'n':
|
||||
value += '\n';
|
||||
break;
|
||||
case 'r':
|
||||
value += '\r';
|
||||
break;
|
||||
case 't':
|
||||
value += '\t';
|
||||
break;
|
||||
default:
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::vector<PersistedTab> ReadTabs(const std::string& json) {
|
||||
std::vector<PersistedTab> tabs;
|
||||
const size_t tabs_pos = json.find("\"tabs\"");
|
||||
if (tabs_pos == std::string::npos) {
|
||||
return tabs;
|
||||
}
|
||||
|
||||
const size_t array_start = json.find('[', tabs_pos);
|
||||
const size_t array_end = json.find(']', array_start == std::string::npos ? tabs_pos : array_start);
|
||||
if (array_start == std::string::npos || array_end == std::string::npos) {
|
||||
return tabs;
|
||||
}
|
||||
|
||||
size_t cursor = array_start + 1;
|
||||
while (cursor < array_end && tabs.size() < kMaxRestoredTabs) {
|
||||
const size_t object_start = json.find('{', cursor);
|
||||
if (object_start == std::string::npos || object_start >= array_end) {
|
||||
break;
|
||||
}
|
||||
|
||||
const size_t object_end = json.find('}', object_start + 1);
|
||||
if (object_end == std::string::npos || object_end > array_end) {
|
||||
break;
|
||||
}
|
||||
|
||||
const std::string object = json.substr(object_start, object_end - object_start + 1);
|
||||
const auto url = ReadStringValue(object, "\"url\"");
|
||||
if (url && !url->empty()) {
|
||||
PersistedTab tab;
|
||||
tab.url = *url;
|
||||
if (const auto title = ReadStringValue(object, "\"title\""); title && !title->empty()) {
|
||||
tab.title = *title;
|
||||
}
|
||||
tabs.push_back(std::move(tab));
|
||||
}
|
||||
|
||||
cursor = object_end + 1;
|
||||
}
|
||||
|
||||
return tabs;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
SessionState LoadSessionState() {
|
||||
SessionState state;
|
||||
const std::string json = ReadFile(nebula::ui::GetSessionStatePath());
|
||||
if (json.empty()) {
|
||||
return state;
|
||||
}
|
||||
|
||||
state.tabs = ReadTabs(json);
|
||||
if (const auto active_index = ReadUnsignedValue(json, "\"activeTabIndex\"")) {
|
||||
state.active_tab_index = *active_index;
|
||||
}
|
||||
|
||||
if (!state.tabs.empty()) {
|
||||
state.active_tab_index = std::min(state.active_tab_index, state.tabs.size() - 1);
|
||||
} else {
|
||||
state.active_tab_index = 0;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
void SaveSessionState(const std::vector<NebulaTab>& tabs, size_t active_tab_index) {
|
||||
const auto path = nebula::ui::GetSessionStatePath();
|
||||
if (path.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::ostringstream json;
|
||||
json << "{\n \"activeTabIndex\": " << active_tab_index << ",\n \"tabs\": [\n";
|
||||
|
||||
bool wrote_tab = false;
|
||||
for (const auto& tab : tabs) {
|
||||
if (tab.url.empty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (wrote_tab) {
|
||||
json << ",\n";
|
||||
}
|
||||
|
||||
json << " {\"url\": \"" << JsonEscape(tab.url)
|
||||
<< "\", \"title\": \"" << JsonEscape(tab.title) << "\"}";
|
||||
wrote_tab = true;
|
||||
}
|
||||
|
||||
json << "\n ]\n}\n";
|
||||
|
||||
std::filesystem::path temp_path = path;
|
||||
temp_path += L".tmp";
|
||||
{
|
||||
std::ofstream output(temp_path, std::ios::binary | std::ios::trunc);
|
||||
if (!output) {
|
||||
return;
|
||||
}
|
||||
output << json.str();
|
||||
}
|
||||
|
||||
std::error_code ec;
|
||||
std::filesystem::remove(path, ec);
|
||||
ec.clear();
|
||||
std::filesystem::rename(temp_path, path, ec);
|
||||
}
|
||||
|
||||
} // namespace nebula::browser
|
||||
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "browser/tab.h"
|
||||
|
||||
namespace nebula::browser {
|
||||
|
||||
struct PersistedTab {
|
||||
std::string url;
|
||||
std::string title = "New Tab";
|
||||
};
|
||||
|
||||
struct SessionState {
|
||||
std::vector<PersistedTab> tabs;
|
||||
size_t active_tab_index = 0;
|
||||
};
|
||||
|
||||
SessionState LoadSessionState();
|
||||
void SaveSessionState(const std::vector<NebulaTab>& tabs, size_t active_tab_index);
|
||||
|
||||
} // namespace nebula::browser
|
||||
@@ -0,0 +1,13 @@
|
||||
#include "browser/tab.h"
|
||||
|
||||
namespace nebula::browser {
|
||||
|
||||
bool NebulaTab::CanGoBack() const {
|
||||
return browser && browser->CanGoBack();
|
||||
}
|
||||
|
||||
bool NebulaTab::CanGoForward() const {
|
||||
return browser && browser->CanGoForward();
|
||||
}
|
||||
|
||||
} // namespace nebula::browser
|
||||
@@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "include/cef_browser.h"
|
||||
|
||||
namespace nebula::browser {
|
||||
|
||||
struct NebulaTab {
|
||||
int id = 1;
|
||||
std::string url;
|
||||
std::string title = "New Tab";
|
||||
bool is_loading = false;
|
||||
double load_progress = 0.0;
|
||||
std::string favicon_url;
|
||||
CefRefPtr<CefBrowser> browser;
|
||||
|
||||
bool CanGoBack() const;
|
||||
bool CanGoForward() const;
|
||||
};
|
||||
|
||||
} // namespace nebula::browser
|
||||
@@ -0,0 +1,273 @@
|
||||
#include "browser/tab_manager.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "browser/url_utils.h"
|
||||
#include "ui/paths.h"
|
||||
|
||||
namespace nebula::browser {
|
||||
|
||||
TabManager::TabManager(TabObserver* observer) : observer_(observer) {}
|
||||
|
||||
NebulaTab& TabManager::CreateInitialTab(std::string initial_url) {
|
||||
tabs_.clear();
|
||||
NebulaTab tab;
|
||||
tab.id = next_tab_id_++;
|
||||
tab.url = std::move(initial_url);
|
||||
tabs_.push_back(std::move(tab));
|
||||
active_tab_id_ = tabs_.front().id;
|
||||
Notify();
|
||||
return tabs_.front();
|
||||
}
|
||||
|
||||
NebulaTab& TabManager::CreateTab(std::string url) {
|
||||
NebulaTab tab;
|
||||
tab.id = next_tab_id_++;
|
||||
tab.url = std::move(url);
|
||||
tabs_.push_back(std::move(tab));
|
||||
active_tab_id_ = tabs_.back().id;
|
||||
Notify();
|
||||
return tabs_.back();
|
||||
}
|
||||
|
||||
void TabManager::RestoreTabs(const std::vector<PersistedTab>& tabs, size_t active_tab_index) {
|
||||
tabs_.clear();
|
||||
active_tab_id_ = 0;
|
||||
|
||||
for (const auto& restored_tab : tabs) {
|
||||
NebulaTab tab;
|
||||
tab.id = next_tab_id_++;
|
||||
tab.url = restored_tab.url.empty() ? nebula::ui::GetHomeUrl() : restored_tab.url;
|
||||
tab.title = restored_tab.title.empty() ? "New Tab" : restored_tab.title;
|
||||
tabs_.push_back(std::move(tab));
|
||||
}
|
||||
|
||||
if (tabs_.empty()) {
|
||||
CreateInitialTab(nebula::ui::GetHomeUrl());
|
||||
return;
|
||||
}
|
||||
|
||||
active_tab_index = std::min(active_tab_index, tabs_.size() - 1);
|
||||
active_tab_id_ = tabs_[active_tab_index].id;
|
||||
Notify();
|
||||
}
|
||||
|
||||
NebulaTab* TabManager::ActiveTab() {
|
||||
for (auto& tab : tabs_) {
|
||||
if (tab.id == active_tab_id_) {
|
||||
return &tab;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const NebulaTab* TabManager::ActiveTab() const {
|
||||
for (const auto& tab : tabs_) {
|
||||
if (tab.id == active_tab_id_) {
|
||||
return &tab;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const std::vector<NebulaTab>& TabManager::Tabs() const {
|
||||
return tabs_;
|
||||
}
|
||||
|
||||
size_t TabManager::ActiveTabIndex() const {
|
||||
for (size_t i = 0; i < tabs_.size(); ++i) {
|
||||
if (tabs_[i].id == active_tab_id_) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool TabManager::ActivateTab(int tab_id) {
|
||||
if (!FindTab(tab_id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
active_tab_id_ = tab_id;
|
||||
Notify();
|
||||
return true;
|
||||
}
|
||||
|
||||
CefRefPtr<CefBrowser> TabManager::CloseTab(int tab_id) {
|
||||
for (auto it = tabs_.begin(); it != tabs_.end(); ++it) {
|
||||
if (it->id != tab_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
CefRefPtr<CefBrowser> browser = it->browser;
|
||||
const bool was_active = it->id == active_tab_id_;
|
||||
const auto next_it = tabs_.erase(it);
|
||||
|
||||
if (tabs_.empty()) {
|
||||
active_tab_id_ = 0;
|
||||
} else if (was_active) {
|
||||
active_tab_id_ = next_it != tabs_.end() ? next_it->id : tabs_.back().id;
|
||||
}
|
||||
|
||||
Notify();
|
||||
return browser;
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void TabManager::SetActiveBrowser(CefRefPtr<CefBrowser> browser) {
|
||||
if (NebulaTab* tab = ActiveTab()) {
|
||||
tab->browser = browser;
|
||||
if (browser && tab->url.empty()) {
|
||||
tab->url = browser->GetMainFrame()->GetURL();
|
||||
}
|
||||
Notify();
|
||||
}
|
||||
}
|
||||
|
||||
bool TabManager::OwnsBrowser(CefRefPtr<CefBrowser> browser) const {
|
||||
if (!browser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const auto& tab : tabs_) {
|
||||
if (tab.browser && tab.browser->IsSame(browser)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool TabManager::HasOpenBrowsers() const {
|
||||
for (const auto& tab : tabs_) {
|
||||
if (tab.browser) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void TabManager::ClearBrowser(CefRefPtr<CefBrowser> browser) {
|
||||
if (NebulaTab* tab = FindTab(browser)) {
|
||||
tab->browser = nullptr;
|
||||
Notify();
|
||||
}
|
||||
}
|
||||
|
||||
void TabManager::LoadURL(const std::string& input) {
|
||||
NebulaTab* tab = ActiveTab();
|
||||
if (!tab || !tab->browser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string target = NormalizeNavigationInput(input);
|
||||
if (target.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
tab->url = target;
|
||||
tab->favicon_url.clear();
|
||||
tab->browser->GetMainFrame()->LoadURL(nebula::ui::ResolveInternalUrl(target));
|
||||
Notify();
|
||||
}
|
||||
|
||||
void TabManager::GoBack() {
|
||||
NebulaTab* tab = ActiveTab();
|
||||
if (tab && tab->browser && tab->browser->CanGoBack()) {
|
||||
tab->browser->GoBack();
|
||||
}
|
||||
}
|
||||
|
||||
void TabManager::GoForward() {
|
||||
NebulaTab* tab = ActiveTab();
|
||||
if (tab && tab->browser && tab->browser->CanGoForward()) {
|
||||
tab->browser->GoForward();
|
||||
}
|
||||
}
|
||||
|
||||
void TabManager::Reload() {
|
||||
NebulaTab* tab = ActiveTab();
|
||||
if (tab && tab->browser) {
|
||||
tab->browser->Reload();
|
||||
}
|
||||
}
|
||||
|
||||
void TabManager::StopLoad() {
|
||||
NebulaTab* tab = ActiveTab();
|
||||
if (tab && tab->browser) {
|
||||
tab->browser->StopLoad();
|
||||
}
|
||||
}
|
||||
|
||||
void TabManager::UpdateURL(CefRefPtr<CefBrowser> browser, std::string url) {
|
||||
if (NebulaTab* tab = FindTab(browser)) {
|
||||
tab->url = std::move(url);
|
||||
Notify();
|
||||
}
|
||||
}
|
||||
|
||||
void TabManager::UpdateTitle(CefRefPtr<CefBrowser> browser, std::string title) {
|
||||
if (NebulaTab* tab = FindTab(browser)) {
|
||||
tab->title = title.empty() ? "New Tab" : std::move(title);
|
||||
Notify();
|
||||
}
|
||||
}
|
||||
|
||||
void TabManager::UpdateLoadingState(CefRefPtr<CefBrowser> browser, bool is_loading) {
|
||||
if (NebulaTab* tab = FindTab(browser)) {
|
||||
tab->is_loading = is_loading;
|
||||
if (is_loading) {
|
||||
tab->favicon_url.clear();
|
||||
}
|
||||
if (!is_loading) {
|
||||
tab->load_progress = 1.0;
|
||||
}
|
||||
Notify();
|
||||
}
|
||||
}
|
||||
|
||||
void TabManager::UpdateLoadProgress(CefRefPtr<CefBrowser> browser, double progress) {
|
||||
if (NebulaTab* tab = FindTab(browser)) {
|
||||
tab->load_progress = progress;
|
||||
Notify();
|
||||
}
|
||||
}
|
||||
|
||||
void TabManager::UpdateFavicon(CefRefPtr<CefBrowser> browser, const std::vector<std::string>& urls) {
|
||||
if (NebulaTab* tab = FindTab(browser)) {
|
||||
tab->favicon_url = urls.empty() ? std::string{} : urls.front();
|
||||
Notify();
|
||||
}
|
||||
}
|
||||
|
||||
void TabManager::Notify() {
|
||||
const NebulaTab* tab = ActiveTab();
|
||||
if (observer_ && tab) {
|
||||
observer_->OnActiveTabChanged(*tab);
|
||||
}
|
||||
}
|
||||
|
||||
NebulaTab* TabManager::FindTab(int tab_id) {
|
||||
for (auto& tab : tabs_) {
|
||||
if (tab.id == tab_id) {
|
||||
return &tab;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
NebulaTab* TabManager::FindTab(CefRefPtr<CefBrowser> browser) {
|
||||
if (!browser) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
for (auto& tab : tabs_) {
|
||||
if (tab.browser && tab.browser->IsSame(browser)) {
|
||||
return &tab;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
} // namespace nebula::browser
|
||||
@@ -0,0 +1,60 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "browser/tab.h"
|
||||
#include "browser/session_state.h"
|
||||
|
||||
namespace nebula::browser {
|
||||
|
||||
class TabObserver {
|
||||
public:
|
||||
virtual ~TabObserver() = default;
|
||||
virtual void OnActiveTabChanged(const NebulaTab& tab) = 0;
|
||||
};
|
||||
|
||||
class TabManager {
|
||||
public:
|
||||
explicit TabManager(TabObserver* observer);
|
||||
|
||||
NebulaTab& CreateInitialTab(std::string initial_url);
|
||||
NebulaTab& CreateTab(std::string url);
|
||||
void RestoreTabs(const std::vector<PersistedTab>& tabs, size_t active_tab_index);
|
||||
NebulaTab* ActiveTab();
|
||||
const NebulaTab* ActiveTab() const;
|
||||
const std::vector<NebulaTab>& Tabs() const;
|
||||
size_t ActiveTabIndex() const;
|
||||
|
||||
bool ActivateTab(int tab_id);
|
||||
CefRefPtr<CefBrowser> CloseTab(int tab_id);
|
||||
void SetActiveBrowser(CefRefPtr<CefBrowser> browser);
|
||||
bool OwnsBrowser(CefRefPtr<CefBrowser> browser) const;
|
||||
void ClearBrowser(CefRefPtr<CefBrowser> browser);
|
||||
bool HasOpenBrowsers() const;
|
||||
|
||||
void LoadURL(const std::string& input);
|
||||
void GoBack();
|
||||
void GoForward();
|
||||
void Reload();
|
||||
void StopLoad();
|
||||
|
||||
void UpdateURL(CefRefPtr<CefBrowser> browser, std::string url);
|
||||
void UpdateTitle(CefRefPtr<CefBrowser> browser, std::string title);
|
||||
void UpdateLoadingState(CefRefPtr<CefBrowser> browser, bool is_loading);
|
||||
void UpdateLoadProgress(CefRefPtr<CefBrowser> browser, double progress);
|
||||
void UpdateFavicon(CefRefPtr<CefBrowser> browser, const std::vector<std::string>& urls);
|
||||
|
||||
private:
|
||||
void Notify();
|
||||
NebulaTab* FindTab(int tab_id);
|
||||
NebulaTab* FindTab(CefRefPtr<CefBrowser> browser);
|
||||
|
||||
TabObserver* observer_ = nullptr;
|
||||
std::vector<NebulaTab> tabs_;
|
||||
int active_tab_id_ = 0;
|
||||
int next_tab_id_ = 1;
|
||||
};
|
||||
|
||||
} // namespace nebula::browser
|
||||
@@ -0,0 +1,115 @@
|
||||
#include "browser/url_utils.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
|
||||
namespace nebula::browser {
|
||||
namespace {
|
||||
|
||||
constexpr char kSearchUrl[] = "https://www.google.com/search?q=";
|
||||
|
||||
std::string Trim(std::string value) {
|
||||
while (!value.empty() && std::isspace(static_cast<unsigned char>(value.front()))) {
|
||||
value.erase(value.begin());
|
||||
}
|
||||
while (!value.empty() && std::isspace(static_cast<unsigned char>(value.back()))) {
|
||||
value.pop_back();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
bool StartsWithScheme(std::string value) {
|
||||
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char ch) {
|
||||
return static_cast<char>(std::tolower(ch));
|
||||
});
|
||||
return value.starts_with("http://") ||
|
||||
value.starts_with("https://") ||
|
||||
value.starts_with("file:") ||
|
||||
value.starts_with("data:") ||
|
||||
value.starts_with("blob:") ||
|
||||
value.starts_with("chrome:") ||
|
||||
value.starts_with("nebula://");
|
||||
}
|
||||
|
||||
bool LooksLikeHostName(const std::string& value) {
|
||||
return value.find('.') != std::string::npos &&
|
||||
value.find_first_of(" \t\r\n") == std::string::npos;
|
||||
}
|
||||
|
||||
std::string UrlEncodeSearch(const std::string& value) {
|
||||
std::ostringstream encoded;
|
||||
encoded << std::hex << std::uppercase;
|
||||
|
||||
for (unsigned char ch : value) {
|
||||
if (std::isalnum(ch) || ch == '-' || ch == '_' || ch == '.' || ch == '~') {
|
||||
encoded << static_cast<char>(ch);
|
||||
} else if (ch == ' ') {
|
||||
encoded << '+';
|
||||
} else {
|
||||
encoded << '%' << std::setw(2) << std::setfill('0') << static_cast<int>(ch);
|
||||
}
|
||||
}
|
||||
|
||||
return encoded.str();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string NormalizeNavigationInput(const std::string& input) {
|
||||
const std::string value = Trim(input);
|
||||
if (value.empty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (StartsWithScheme(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (LooksLikeHostName(value)) {
|
||||
return "https://" + value;
|
||||
}
|
||||
|
||||
return std::string(kSearchUrl) + UrlEncodeSearch(value);
|
||||
}
|
||||
|
||||
std::string JsonEscape(const std::string& value) {
|
||||
std::ostringstream escaped;
|
||||
for (unsigned char ch : value) {
|
||||
switch (ch) {
|
||||
case '\\':
|
||||
escaped << "\\\\";
|
||||
break;
|
||||
case '"':
|
||||
escaped << "\\\"";
|
||||
break;
|
||||
case '\b':
|
||||
escaped << "\\b";
|
||||
break;
|
||||
case '\f':
|
||||
escaped << "\\f";
|
||||
break;
|
||||
case '\n':
|
||||
escaped << "\\n";
|
||||
break;
|
||||
case '\r':
|
||||
escaped << "\\r";
|
||||
break;
|
||||
case '\t':
|
||||
escaped << "\\t";
|
||||
break;
|
||||
default:
|
||||
if (ch < 0x20) {
|
||||
escaped << "\\u" << std::hex << std::uppercase << std::setw(4)
|
||||
<< std::setfill('0') << static_cast<int>(ch);
|
||||
} else {
|
||||
escaped << static_cast<char>(ch);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return escaped.str();
|
||||
}
|
||||
|
||||
} // namespace nebula::browser
|
||||
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace nebula::browser {
|
||||
|
||||
std::string NormalizeNavigationInput(const std::string& input);
|
||||
std::string JsonEscape(const std::string& value);
|
||||
|
||||
} // namespace nebula::browser
|
||||
@@ -0,0 +1,280 @@
|
||||
#include "cef/browser_client.h"
|
||||
|
||||
#include "include/cef_request.h"
|
||||
#include "include/wrapper/cef_helpers.h"
|
||||
#include "ui/paths.h"
|
||||
|
||||
namespace nebula::cef {
|
||||
namespace {
|
||||
|
||||
constexpr char kChromeCommandMessage[] = "NebulaChromeCommand";
|
||||
|
||||
bool IsInsecureInterstitialFrame(CefRefPtr<CefFrame> frame) {
|
||||
if (!frame) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return nebula::ui::ToInternalUrl(frame->GetURL().ToString()).starts_with("nebula://insecure");
|
||||
}
|
||||
|
||||
bool IsSettingsFrame(CefRefPtr<CefFrame> frame) {
|
||||
if (!frame) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return nebula::ui::ToInternalUrl(frame->GetURL().ToString()).starts_with("nebula://settings");
|
||||
}
|
||||
|
||||
std::vector<std::string> ToStringVector(const std::vector<CefString>& values) {
|
||||
std::vector<std::string> result;
|
||||
result.reserve(values.size());
|
||||
for (const auto& value : values) {
|
||||
result.push_back(value.ToString());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
NebulaBrowserClient::NebulaBrowserClient(BrowserRole role, BrowserClientDelegate* delegate)
|
||||
: role_(role), delegate_(delegate) {}
|
||||
|
||||
bool NebulaBrowserClient::OnProcessMessageReceived(CefRefPtr<CefBrowser> browser,
|
||||
CefRefPtr<CefFrame> frame,
|
||||
CefProcessId source_process,
|
||||
CefRefPtr<CefProcessMessage> message) {
|
||||
CEF_REQUIRE_UI_THREAD();
|
||||
UNREFERENCED_PARAMETER(browser);
|
||||
UNREFERENCED_PARAMETER(source_process);
|
||||
|
||||
if (!message || message->GetName().ToString() != kChromeCommandMessage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (role_ != BrowserRole::Chrome && role_ != BrowserRole::MenuPopup &&
|
||||
role_ != BrowserRole::Content) {
|
||||
return false;
|
||||
}
|
||||
|
||||
CefRefPtr<CefListValue> args = message->GetArgumentList();
|
||||
const std::string command = args && args->GetSize() > 0 ? args->GetString(0).ToString() : "";
|
||||
const std::string payload = args && args->GetSize() > 1 ? args->GetString(1).ToString() : "";
|
||||
if (role_ == BrowserRole::Content) {
|
||||
const bool allowed_insecure_command =
|
||||
command == "navigate-insecure" && IsInsecureInterstitialFrame(frame);
|
||||
const bool allowed_settings_command =
|
||||
IsSettingsFrame(frame) && (command == "navigate" ||
|
||||
command == "clear-site-history" ||
|
||||
command == "clear-search-history");
|
||||
if (!allowed_insecure_command && !allowed_settings_command) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (delegate_ && !command.empty()) {
|
||||
delegate_->OnChromeCommand(command, payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void NebulaBrowserClient::OnAddressChange(CefRefPtr<CefBrowser> browser,
|
||||
CefRefPtr<CefFrame> frame,
|
||||
const CefString& url) {
|
||||
CEF_REQUIRE_UI_THREAD();
|
||||
if (role_ == BrowserRole::Content && delegate_ && frame && frame->IsMain()) {
|
||||
delegate_->OnContentAddressChanged(browser, url.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
void NebulaBrowserClient::OnTitleChange(CefRefPtr<CefBrowser> browser,
|
||||
const CefString& title) {
|
||||
CEF_REQUIRE_UI_THREAD();
|
||||
if (role_ == BrowserRole::Content && delegate_) {
|
||||
delegate_->OnContentTitleChanged(browser, title.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
void NebulaBrowserClient::OnFaviconURLChange(CefRefPtr<CefBrowser> browser,
|
||||
const std::vector<CefString>& icon_urls) {
|
||||
CEF_REQUIRE_UI_THREAD();
|
||||
if (role_ == BrowserRole::Content && delegate_) {
|
||||
delegate_->OnContentFaviconChanged(browser, ToStringVector(icon_urls));
|
||||
}
|
||||
}
|
||||
|
||||
void NebulaBrowserClient::OnFullscreenModeChange(CefRefPtr<CefBrowser> browser, bool fullscreen) {
|
||||
CEF_REQUIRE_UI_THREAD();
|
||||
if (role_ == BrowserRole::Content && delegate_) {
|
||||
delegate_->OnContentFullscreenChanged(browser, fullscreen);
|
||||
}
|
||||
}
|
||||
|
||||
bool NebulaBrowserClient::OnPreKeyEvent(CefRefPtr<CefBrowser> browser,
|
||||
const CefKeyEvent& event,
|
||||
CefEventHandle os_event,
|
||||
bool* is_keyboard_shortcut) {
|
||||
CEF_REQUIRE_UI_THREAD();
|
||||
UNREFERENCED_PARAMETER(os_event);
|
||||
|
||||
if (role_ == BrowserRole::Content &&
|
||||
event.type == KEYEVENT_RAWKEYDOWN &&
|
||||
(event.modifiers & EVENTFLAG_CONTROL_DOWN) != 0 &&
|
||||
event.windows_key_code == 'T') {
|
||||
if (is_keyboard_shortcut) {
|
||||
*is_keyboard_shortcut = true;
|
||||
}
|
||||
if (delegate_) {
|
||||
delegate_->OnPopupRequested(browser, nebula::ui::GetHomeUrl());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool NebulaBrowserClient::OnBeforePopup(CefRefPtr<CefBrowser> browser,
|
||||
CefRefPtr<CefFrame> frame,
|
||||
int popup_id,
|
||||
const CefString& target_url,
|
||||
const CefString& target_frame_name,
|
||||
CefLifeSpanHandler::WindowOpenDisposition target_disposition,
|
||||
bool user_gesture,
|
||||
const CefPopupFeatures& popupFeatures,
|
||||
CefWindowInfo& windowInfo,
|
||||
CefRefPtr<CefClient>& client,
|
||||
CefBrowserSettings& settings,
|
||||
CefRefPtr<CefDictionaryValue>& extra_info,
|
||||
bool* no_javascript_access) {
|
||||
CEF_REQUIRE_UI_THREAD();
|
||||
UNREFERENCED_PARAMETER(frame);
|
||||
UNREFERENCED_PARAMETER(popup_id);
|
||||
UNREFERENCED_PARAMETER(target_frame_name);
|
||||
UNREFERENCED_PARAMETER(target_disposition);
|
||||
UNREFERENCED_PARAMETER(user_gesture);
|
||||
UNREFERENCED_PARAMETER(popupFeatures);
|
||||
UNREFERENCED_PARAMETER(windowInfo);
|
||||
UNREFERENCED_PARAMETER(client);
|
||||
UNREFERENCED_PARAMETER(settings);
|
||||
UNREFERENCED_PARAMETER(extra_info);
|
||||
UNREFERENCED_PARAMETER(no_javascript_access);
|
||||
|
||||
if (role_ == BrowserRole::Content && delegate_) {
|
||||
delegate_->OnPopupRequested(browser, target_url.ToString());
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void NebulaBrowserClient::OnAfterCreated(CefRefPtr<CefBrowser> browser) {
|
||||
CEF_REQUIRE_UI_THREAD();
|
||||
if (delegate_) {
|
||||
delegate_->OnBrowserCreated(role_, browser);
|
||||
}
|
||||
}
|
||||
|
||||
void NebulaBrowserClient::OnBeforeClose(CefRefPtr<CefBrowser> browser) {
|
||||
CEF_REQUIRE_UI_THREAD();
|
||||
if (delegate_) {
|
||||
delegate_->OnBrowserClosing(role_, browser);
|
||||
}
|
||||
}
|
||||
|
||||
void NebulaBrowserClient::OnLoadingStateChange(CefRefPtr<CefBrowser> browser,
|
||||
bool isLoading,
|
||||
bool canGoBack,
|
||||
bool canGoForward) {
|
||||
CEF_REQUIRE_UI_THREAD();
|
||||
UNREFERENCED_PARAMETER(canGoBack);
|
||||
UNREFERENCED_PARAMETER(canGoForward);
|
||||
|
||||
if (role_ == BrowserRole::Content && delegate_) {
|
||||
delegate_->OnContentLoadingStateChanged(browser, isLoading);
|
||||
}
|
||||
}
|
||||
|
||||
void NebulaBrowserClient::OnLoadStart(CefRefPtr<CefBrowser> browser,
|
||||
CefRefPtr<CefFrame> frame,
|
||||
TransitionType transition_type) {
|
||||
CEF_REQUIRE_UI_THREAD();
|
||||
UNREFERENCED_PARAMETER(transition_type);
|
||||
|
||||
if (role_ == BrowserRole::Content && delegate_ && frame && frame->IsMain()) {
|
||||
delegate_->OnContentLoadProgressChanged(browser, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
void NebulaBrowserClient::OnLoadEnd(CefRefPtr<CefBrowser> browser,
|
||||
CefRefPtr<CefFrame> frame,
|
||||
int httpStatusCode) {
|
||||
CEF_REQUIRE_UI_THREAD();
|
||||
|
||||
if (role_ == BrowserRole::Content && delegate_ && frame && frame->IsMain()) {
|
||||
if (httpStatusCode == 404) {
|
||||
frame->LoadURL(nebula::ui::ResolveInternalUrl(
|
||||
nebula::ui::GetNotFoundUrl(frame->GetURL().ToString())));
|
||||
return;
|
||||
}
|
||||
|
||||
delegate_->OnContentLoadProgressChanged(browser, 1.0);
|
||||
delegate_->OnContentLoadFinished(browser, frame->GetURL().ToString());
|
||||
}
|
||||
}
|
||||
|
||||
bool NebulaBrowserClient::OnBeforeBrowse(CefRefPtr<CefBrowser> browser,
|
||||
CefRefPtr<CefFrame> frame,
|
||||
CefRefPtr<CefRequest> request,
|
||||
bool user_gesture,
|
||||
bool is_redirect) {
|
||||
CEF_REQUIRE_UI_THREAD();
|
||||
UNREFERENCED_PARAMETER(browser);
|
||||
UNREFERENCED_PARAMETER(user_gesture);
|
||||
UNREFERENCED_PARAMETER(is_redirect);
|
||||
|
||||
if (role_ == BrowserRole::Content && frame && frame->IsMain() && request) {
|
||||
const std::string url = request->GetURL().ToString();
|
||||
if (nebula::ui::IsChromiumNewTabUrl(url)) {
|
||||
frame->LoadURL(nebula::ui::ResolveInternalUrl(nebula::ui::GetHomeUrl()));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (nebula::ui::IsNebulaInternalUrl(url)) {
|
||||
frame->LoadURL(nebula::ui::ResolveInternalUrl(url));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (nebula::ui::IsHttpUrl(url) &&
|
||||
(!delegate_ || !delegate_->ShouldBypassInsecureWarning(browser, url))) {
|
||||
frame->LoadURL(nebula::ui::ResolveInternalUrl(
|
||||
nebula::ui::GetInsecureWarningUrl(url)));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool NebulaBrowserClient::OnShowPermissionPrompt(
|
||||
CefRefPtr<CefBrowser> browser,
|
||||
uint64_t prompt_id,
|
||||
const CefString& requesting_origin,
|
||||
uint32_t requested_permissions,
|
||||
CefRefPtr<CefPermissionPromptCallback> callback) {
|
||||
CEF_REQUIRE_UI_THREAD();
|
||||
UNREFERENCED_PARAMETER(prompt_id);
|
||||
UNREFERENCED_PARAMETER(requesting_origin);
|
||||
|
||||
if (role_ == BrowserRole::Content &&
|
||||
(requested_permissions & CEF_PERMISSION_TYPE_GEOLOCATION) != 0 &&
|
||||
browser && callback &&
|
||||
nebula::ui::IsInternalHomeUrl(browser->GetMainFrame()->GetURL().ToString())) {
|
||||
callback->Continue(CEF_PERMISSION_RESULT_ACCEPT);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace nebula::cef
|
||||
@@ -0,0 +1,122 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "include/cef_client.h"
|
||||
#include "include/cef_display_handler.h"
|
||||
#include "include/cef_keyboard_handler.h"
|
||||
#include "include/cef_life_span_handler.h"
|
||||
#include "include/cef_load_handler.h"
|
||||
#include "include/cef_permission_handler.h"
|
||||
#include "include/cef_request_handler.h"
|
||||
|
||||
namespace nebula::cef {
|
||||
|
||||
enum class BrowserRole {
|
||||
Chrome,
|
||||
Content,
|
||||
MenuPopup,
|
||||
};
|
||||
|
||||
class BrowserClientDelegate {
|
||||
public:
|
||||
virtual ~BrowserClientDelegate() = default;
|
||||
virtual void OnBrowserCreated(BrowserRole role, CefRefPtr<CefBrowser> browser) = 0;
|
||||
virtual void OnBrowserClosing(BrowserRole role, CefRefPtr<CefBrowser> browser) = 0;
|
||||
virtual void OnChromeCommand(const std::string& command, const std::string& payload) = 0;
|
||||
virtual void OnContentAddressChanged(CefRefPtr<CefBrowser> browser, const std::string& url) = 0;
|
||||
virtual void OnContentTitleChanged(CefRefPtr<CefBrowser> browser, const std::string& title) = 0;
|
||||
virtual void OnContentLoadingStateChanged(CefRefPtr<CefBrowser> browser, bool is_loading) = 0;
|
||||
virtual void OnContentLoadProgressChanged(CefRefPtr<CefBrowser> browser, double progress) = 0;
|
||||
virtual void OnContentLoadFinished(CefRefPtr<CefBrowser> browser, const std::string& url) = 0;
|
||||
virtual void OnContentFaviconChanged(CefRefPtr<CefBrowser> browser, const std::vector<std::string>& urls) = 0;
|
||||
virtual void OnContentFullscreenChanged(CefRefPtr<CefBrowser> browser, bool fullscreen) = 0;
|
||||
virtual void OnPopupRequested(CefRefPtr<CefBrowser> browser, const std::string& target_url) = 0;
|
||||
virtual bool ShouldBypassInsecureWarning(CefRefPtr<CefBrowser> browser, const std::string& target_url) = 0;
|
||||
};
|
||||
|
||||
class NebulaBrowserClient final : public CefClient,
|
||||
public CefDisplayHandler,
|
||||
public CefKeyboardHandler,
|
||||
public CefLifeSpanHandler,
|
||||
public CefLoadHandler,
|
||||
public CefPermissionHandler,
|
||||
public CefRequestHandler {
|
||||
public:
|
||||
NebulaBrowserClient(BrowserRole role, BrowserClientDelegate* delegate);
|
||||
|
||||
CefRefPtr<CefDisplayHandler> GetDisplayHandler() override { return this; }
|
||||
CefRefPtr<CefKeyboardHandler> GetKeyboardHandler() override { return this; }
|
||||
CefRefPtr<CefLifeSpanHandler> GetLifeSpanHandler() override { return this; }
|
||||
CefRefPtr<CefLoadHandler> GetLoadHandler() override { return this; }
|
||||
CefRefPtr<CefPermissionHandler> GetPermissionHandler() override { return this; }
|
||||
CefRefPtr<CefRequestHandler> GetRequestHandler() override { return this; }
|
||||
|
||||
bool OnProcessMessageReceived(CefRefPtr<CefBrowser> browser,
|
||||
CefRefPtr<CefFrame> frame,
|
||||
CefProcessId source_process,
|
||||
CefRefPtr<CefProcessMessage> message) override;
|
||||
|
||||
void OnAddressChange(CefRefPtr<CefBrowser> browser,
|
||||
CefRefPtr<CefFrame> frame,
|
||||
const CefString& url) override;
|
||||
void OnTitleChange(CefRefPtr<CefBrowser> browser,
|
||||
const CefString& title) override;
|
||||
void OnFaviconURLChange(CefRefPtr<CefBrowser> browser,
|
||||
const std::vector<CefString>& icon_urls) override;
|
||||
void OnFullscreenModeChange(CefRefPtr<CefBrowser> browser, bool fullscreen) override;
|
||||
|
||||
bool OnPreKeyEvent(CefRefPtr<CefBrowser> browser,
|
||||
const CefKeyEvent& event,
|
||||
CefEventHandle os_event,
|
||||
bool* is_keyboard_shortcut) override;
|
||||
|
||||
bool OnBeforePopup(CefRefPtr<CefBrowser> browser,
|
||||
CefRefPtr<CefFrame> frame,
|
||||
int popup_id,
|
||||
const CefString& target_url,
|
||||
const CefString& target_frame_name,
|
||||
CefLifeSpanHandler::WindowOpenDisposition target_disposition,
|
||||
bool user_gesture,
|
||||
const CefPopupFeatures& popupFeatures,
|
||||
CefWindowInfo& windowInfo,
|
||||
CefRefPtr<CefClient>& client,
|
||||
CefBrowserSettings& settings,
|
||||
CefRefPtr<CefDictionaryValue>& extra_info,
|
||||
bool* no_javascript_access) override;
|
||||
|
||||
void OnAfterCreated(CefRefPtr<CefBrowser> browser) override;
|
||||
void OnBeforeClose(CefRefPtr<CefBrowser> browser) override;
|
||||
|
||||
void OnLoadingStateChange(CefRefPtr<CefBrowser> browser,
|
||||
bool isLoading,
|
||||
bool canGoBack,
|
||||
bool canGoForward) override;
|
||||
void OnLoadStart(CefRefPtr<CefBrowser> browser,
|
||||
CefRefPtr<CefFrame> frame,
|
||||
TransitionType transition_type) override;
|
||||
void OnLoadEnd(CefRefPtr<CefBrowser> browser,
|
||||
CefRefPtr<CefFrame> frame,
|
||||
int httpStatusCode) override;
|
||||
|
||||
bool OnBeforeBrowse(CefRefPtr<CefBrowser> browser,
|
||||
CefRefPtr<CefFrame> frame,
|
||||
CefRefPtr<CefRequest> request,
|
||||
bool user_gesture,
|
||||
bool is_redirect) override;
|
||||
|
||||
bool OnShowPermissionPrompt(CefRefPtr<CefBrowser> browser,
|
||||
uint64_t prompt_id,
|
||||
const CefString& requesting_origin,
|
||||
uint32_t requested_permissions,
|
||||
CefRefPtr<CefPermissionPromptCallback> callback) override;
|
||||
|
||||
private:
|
||||
BrowserRole role_;
|
||||
BrowserClientDelegate* delegate_ = nullptr;
|
||||
|
||||
IMPLEMENT_REFCOUNTING(NebulaBrowserClient);
|
||||
};
|
||||
|
||||
} // namespace nebula::cef
|
||||
@@ -0,0 +1,111 @@
|
||||
#include "cef/nebula_app.h"
|
||||
|
||||
#include "include/cef_process_message.h"
|
||||
#include "include/wrapper/cef_helpers.h"
|
||||
|
||||
namespace nebula::cef {
|
||||
namespace {
|
||||
|
||||
constexpr char kChromeCommandMessage[] = "NebulaChromeCommand";
|
||||
|
||||
class NativeBridgeHandler final : public CefV8Handler {
|
||||
public:
|
||||
bool Execute(const CefString& name,
|
||||
CefRefPtr<CefV8Value> object,
|
||||
const CefV8ValueList& arguments,
|
||||
CefRefPtr<CefV8Value>& retval,
|
||||
CefString& exception) override {
|
||||
UNREFERENCED_PARAMETER(object);
|
||||
UNREFERENCED_PARAMETER(retval);
|
||||
|
||||
if (name != "postMessage" && name != "sendToHost" && name != "send") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (arguments.empty() || !arguments[0]->IsString()) {
|
||||
exception = "nebulaNative.postMessage requires a command string.";
|
||||
return true;
|
||||
}
|
||||
|
||||
CefRefPtr<CefV8Context> context = CefV8Context::GetCurrentContext();
|
||||
CefRefPtr<CefBrowser> browser = context ? context->GetBrowser() : nullptr;
|
||||
CefRefPtr<CefFrame> frame = context ? context->GetFrame() : nullptr;
|
||||
if (!browser || !frame) {
|
||||
exception = "No CEF frame is available for native messaging.";
|
||||
return true;
|
||||
}
|
||||
|
||||
CefRefPtr<CefProcessMessage> message = CefProcessMessage::Create(kChromeCommandMessage);
|
||||
CefRefPtr<CefListValue> args = message->GetArgumentList();
|
||||
args->SetString(0, arguments[0]->GetStringValue());
|
||||
args->SetString(1, arguments.size() > 1 && arguments[1]->IsString()
|
||||
? arguments[1]->GetStringValue()
|
||||
: CefString());
|
||||
|
||||
frame->SendProcessMessage(PID_BROWSER, message);
|
||||
return true;
|
||||
}
|
||||
|
||||
private:
|
||||
IMPLEMENT_REFCOUNTING(NativeBridgeHandler);
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
void NebulaApp::OnBeforeCommandLineProcessing(const CefString& process_type,
|
||||
CefRefPtr<CefCommandLine> command_line) {
|
||||
UNREFERENCED_PARAMETER(process_type);
|
||||
|
||||
// The bundled UI is loaded from file:// and uses ES modules.
|
||||
command_line->AppendSwitch("allow-file-access-from-files");
|
||||
|
||||
// CefSettings.no_sandbox disables the browser-level sandbox, but Chromium
|
||||
// still attempts to bring up a separate GPU sandbox inside the GPU process.
|
||||
// Without the host-side sandbox plumbing this fails with STATUS_BREAKPOINT
|
||||
// (-2147483645) immediately on startup, which is exactly what the GPU
|
||||
// diagnostics page was showing - the GPU process crashed three times and
|
||||
// Chromium then fell back to software rendering. Disabling the GPU sandbox
|
||||
// matches the rest of our no_sandbox configuration and lets the GPU
|
||||
// process initialize.
|
||||
command_line->AppendSwitch("no-sandbox");
|
||||
command_line->AppendSwitch("disable-gpu-sandbox");
|
||||
command_line->AppendSwitch("in-process-gpu");
|
||||
|
||||
// Avoid Chromium's conservative GPU blocklist, but let Chromium choose the
|
||||
// safest graphics backend for this machine. Forcing raster/zero-copy paths
|
||||
// can prevent WebGL shared contexts from initializing on some drivers.
|
||||
command_line->AppendSwitch("ignore-gpu-blocklist");
|
||||
command_line->AppendSwitch("enable-accelerated-video-decode");
|
||||
command_line->AppendSwitchWithValue("use-gl", "angle");
|
||||
command_line->AppendSwitchWithValue("use-angle", "d3d11");
|
||||
}
|
||||
|
||||
void NebulaApp::OnContextCreated(CefRefPtr<CefBrowser> browser,
|
||||
CefRefPtr<CefFrame> frame,
|
||||
CefRefPtr<CefV8Context> context) {
|
||||
CEF_REQUIRE_RENDERER_THREAD();
|
||||
UNREFERENCED_PARAMETER(browser);
|
||||
UNREFERENCED_PARAMETER(frame);
|
||||
|
||||
CefRefPtr<CefV8Value> global = context->GetGlobal();
|
||||
CefRefPtr<NativeBridgeHandler> handler = new NativeBridgeHandler();
|
||||
CefRefPtr<CefV8Value> native = CefV8Value::CreateObject(nullptr, nullptr);
|
||||
native->SetValue(
|
||||
"postMessage",
|
||||
CefV8Value::CreateFunction("postMessage", handler),
|
||||
V8_PROPERTY_ATTRIBUTE_NONE);
|
||||
global->SetValue("nebulaNative", native, V8_PROPERTY_ATTRIBUTE_READONLY);
|
||||
|
||||
CefRefPtr<CefV8Value> electron_api = CefV8Value::CreateObject(nullptr, nullptr);
|
||||
electron_api->SetValue(
|
||||
"sendToHost",
|
||||
CefV8Value::CreateFunction("sendToHost", handler),
|
||||
V8_PROPERTY_ATTRIBUTE_NONE);
|
||||
electron_api->SetValue(
|
||||
"send",
|
||||
CefV8Value::CreateFunction("send", handler),
|
||||
V8_PROPERTY_ATTRIBUTE_NONE);
|
||||
global->SetValue("electronAPI", electron_api, V8_PROPERTY_ATTRIBUTE_READONLY);
|
||||
}
|
||||
|
||||
} // namespace nebula::cef
|
||||
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
#include "include/cef_app.h"
|
||||
#include "include/cef_render_process_handler.h"
|
||||
#include "include/cef_v8.h"
|
||||
|
||||
namespace nebula::cef {
|
||||
|
||||
class NebulaApp final : public CefApp,
|
||||
public CefRenderProcessHandler {
|
||||
public:
|
||||
CefRefPtr<CefRenderProcessHandler> GetRenderProcessHandler() override { return this; }
|
||||
|
||||
void OnBeforeCommandLineProcessing(const CefString& process_type,
|
||||
CefRefPtr<CefCommandLine> command_line) override;
|
||||
|
||||
void OnContextCreated(CefRefPtr<CefBrowser> browser,
|
||||
CefRefPtr<CefFrame> frame,
|
||||
CefRefPtr<CefV8Context> context) override;
|
||||
|
||||
private:
|
||||
IMPLEMENT_REFCOUNTING(NebulaApp);
|
||||
};
|
||||
|
||||
} // namespace nebula::cef
|
||||
@@ -0,0 +1,304 @@
|
||||
#include "ui/paths.h"
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <string_view>
|
||||
|
||||
namespace nebula::ui {
|
||||
namespace {
|
||||
|
||||
constexpr std::string_view kNebulaScheme = "nebula://";
|
||||
constexpr std::wstring_view kInternalFallbackPage = L"404.html";
|
||||
|
||||
struct InternalPage {
|
||||
std::string_view slug;
|
||||
std::wstring_view file_name;
|
||||
};
|
||||
|
||||
constexpr InternalPage kInternalPages[] = {
|
||||
{"home", L"home.html"},
|
||||
{"settings", L"settings.html"},
|
||||
{"downloads", L"downloads.html"},
|
||||
{"bigpicture", L"bigpicture.html"},
|
||||
{"big-picture", L"bigpicture.html"},
|
||||
{"gpu-diagnostics", L"gpu-diagnostics.html"},
|
||||
{"setup", L"setup.html"},
|
||||
{"404", L"404.html"},
|
||||
{"insecure", L"insecure.html"},
|
||||
};
|
||||
|
||||
std::string WideToUtf8(const std::wstring& value) {
|
||||
if (value.empty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const int size = WideCharToMultiByte(
|
||||
CP_UTF8, 0, value.data(), static_cast<int>(value.size()),
|
||||
nullptr, 0, nullptr, nullptr);
|
||||
std::string result(size, '\0');
|
||||
WideCharToMultiByte(
|
||||
CP_UTF8, 0, value.data(), static_cast<int>(value.size()),
|
||||
result.data(), size, nullptr, nullptr);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string GetUrlWithoutDecoration(std::string url) {
|
||||
const size_t split = url.find_first_of("?#");
|
||||
if (split != std::string::npos) {
|
||||
url.resize(split);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
std::string GetUrlDecoration(const std::string& url) {
|
||||
const size_t split = url.find_first_of("?#");
|
||||
return split == std::string::npos ? std::string{} : url.substr(split);
|
||||
}
|
||||
|
||||
std::string ToLowerAscii(std::string value) {
|
||||
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char ch) {
|
||||
return static_cast<char>(std::tolower(ch));
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
std::string PageFileUrl(std::wstring_view page_name) {
|
||||
const auto path = GetUiPagePath(std::wstring(page_name));
|
||||
return path.empty() ? std::string{} : FilePathToUrl(path);
|
||||
}
|
||||
|
||||
std::string PercentEncode(const std::string& value) {
|
||||
constexpr char kHex[] = "0123456789ABCDEF";
|
||||
std::string encoded;
|
||||
encoded.reserve(value.size());
|
||||
for (unsigned char ch : value) {
|
||||
if (std::isalnum(ch) || ch == '-' || ch == '_' || ch == '.' || ch == '~') {
|
||||
encoded += static_cast<char>(ch);
|
||||
} else {
|
||||
encoded += '%';
|
||||
encoded += kHex[ch >> 4];
|
||||
encoded += kHex[ch & 0x0F];
|
||||
}
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
std::string InternalPageName(const std::string& url) {
|
||||
std::string target = ToLowerAscii(GetUrlWithoutDecoration(url));
|
||||
if (!target.starts_with(kNebulaScheme)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
target.erase(0, kNebulaScheme.size());
|
||||
while (!target.empty() && target.front() == '/') {
|
||||
target.erase(target.begin());
|
||||
}
|
||||
while (!target.empty() && target.back() == '/') {
|
||||
target.pop_back();
|
||||
}
|
||||
return target.empty() ? "home" : target;
|
||||
}
|
||||
|
||||
std::string InternalUrlForSlug(std::string_view slug) {
|
||||
return std::string(kNebulaScheme) + std::string(slug);
|
||||
}
|
||||
|
||||
const InternalPage* FindInternalPageBySlug(std::string_view slug) {
|
||||
for (const auto& page : kInternalPages) {
|
||||
if (page.slug == slug) {
|
||||
return &page;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const InternalPage* FindInternalPageByFileUrl(const std::string& url) {
|
||||
const std::string base_url = GetUrlWithoutDecoration(url);
|
||||
for (const auto& page : kInternalPages) {
|
||||
if (PageFileUrl(page.file_name) == base_url) {
|
||||
return &page;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::filesystem::path GetExecutableDirectory() {
|
||||
wchar_t exe_path[MAX_PATH] = {};
|
||||
const DWORD length = GetModuleFileNameW(nullptr, exe_path, MAX_PATH);
|
||||
if (length == 0 || length == MAX_PATH) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return std::filesystem::path(exe_path).parent_path();
|
||||
}
|
||||
|
||||
std::filesystem::path GetUserDataDirectory() {
|
||||
std::filesystem::path root;
|
||||
|
||||
wchar_t buffer[MAX_PATH] = {};
|
||||
// Prefer %LOCALAPPDATA% so the profile follows Chromium conventions and
|
||||
// survives executable relocation.
|
||||
const DWORD length =
|
||||
GetEnvironmentVariableW(L"LOCALAPPDATA", buffer, MAX_PATH);
|
||||
if (length > 0 && length < MAX_PATH) {
|
||||
root = std::filesystem::path(buffer);
|
||||
} else {
|
||||
// Fall back to a directory next to the executable so a portable
|
||||
// install still gets a writable profile.
|
||||
root = GetExecutableDirectory();
|
||||
}
|
||||
|
||||
if (root.empty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
std::filesystem::path user_data = root / L"Nebula" / L"User Data";
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(user_data, ec);
|
||||
return user_data;
|
||||
}
|
||||
|
||||
std::filesystem::path GetCacheDirectory() {
|
||||
auto user_data = GetUserDataDirectory();
|
||||
if (user_data.empty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
std::filesystem::path cache = user_data / L"Cache";
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(cache, ec);
|
||||
return cache;
|
||||
}
|
||||
|
||||
std::filesystem::path GetSessionStatePath() {
|
||||
auto user_data = GetUserDataDirectory();
|
||||
return user_data.empty() ? std::filesystem::path{} : user_data / L"session_state.json";
|
||||
}
|
||||
|
||||
std::filesystem::path GetUiPagePath(const std::wstring& page_name) {
|
||||
const auto exe_dir = GetExecutableDirectory();
|
||||
if (exe_dir.empty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return exe_dir / L"ui" / L"pages" / page_name;
|
||||
}
|
||||
|
||||
std::string FilePathToUrl(std::filesystem::path path) {
|
||||
std::string value = WideToUtf8(path.wstring());
|
||||
for (char& ch : value) {
|
||||
if (ch == '\\') {
|
||||
ch = '/';
|
||||
}
|
||||
}
|
||||
|
||||
std::string encoded;
|
||||
encoded.reserve(value.size());
|
||||
for (char ch : value) {
|
||||
encoded += ch == ' ' ? "%20" : std::string(1, ch);
|
||||
}
|
||||
return "file:///" + encoded;
|
||||
}
|
||||
|
||||
std::string GetChromeUrl() {
|
||||
const auto path = GetUiPagePath(L"chrome.html");
|
||||
const std::string fallback = PageFileUrl(L"home.html");
|
||||
return path.empty() ? (fallback.empty() ? "https://www.google.com" : fallback) : FilePathToUrl(path);
|
||||
}
|
||||
|
||||
std::string GetHomeUrl() {
|
||||
return InternalUrlForSlug("home");
|
||||
}
|
||||
|
||||
std::string GetSettingsUrl() {
|
||||
return InternalUrlForSlug("settings");
|
||||
}
|
||||
|
||||
std::string GetDownloadsUrl() {
|
||||
return InternalUrlForSlug("downloads");
|
||||
}
|
||||
|
||||
std::string GetBigPictureUrl() {
|
||||
return InternalUrlForSlug("bigpicture");
|
||||
}
|
||||
|
||||
std::string GetGpuDiagnosticsUrl() {
|
||||
return InternalUrlForSlug("gpu-diagnostics");
|
||||
}
|
||||
|
||||
std::string GetMenuPopupUrl() {
|
||||
const auto path = GetUiPagePath(L"menu-popup.html");
|
||||
const std::string fallback = PageFileUrl(L"home.html");
|
||||
return path.empty() ? (fallback.empty() ? "https://www.google.com" : fallback) : FilePathToUrl(path);
|
||||
}
|
||||
|
||||
std::string GetInsecureWarningUrl(const std::string& target_url) {
|
||||
return InternalUrlForSlug("insecure") + "?target=" + PercentEncode(target_url);
|
||||
}
|
||||
|
||||
std::string GetNotFoundUrl(const std::string& target_url) {
|
||||
return InternalUrlForSlug("404") + "?url=" + PercentEncode(target_url);
|
||||
}
|
||||
|
||||
std::string ResolveInternalUrl(const std::string& url) {
|
||||
const std::string page_name = InternalPageName(url);
|
||||
if (page_name.empty()) {
|
||||
return url;
|
||||
}
|
||||
|
||||
if (const auto* page = FindInternalPageBySlug(page_name)) {
|
||||
const std::string file_url = PageFileUrl(page->file_name);
|
||||
return file_url.empty() ? url : file_url + GetUrlDecoration(url);
|
||||
}
|
||||
|
||||
const std::string fallback_url = PageFileUrl(kInternalFallbackPage);
|
||||
return fallback_url.empty() ? url : fallback_url + "?url=" + PercentEncode(url);
|
||||
}
|
||||
|
||||
std::string ToInternalUrl(const std::string& url) {
|
||||
const std::string page_name = InternalPageName(url);
|
||||
if (!page_name.empty()) {
|
||||
if (const auto* page = FindInternalPageBySlug(page_name)) {
|
||||
return InternalUrlForSlug(page->slug) + GetUrlDecoration(url);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
if (const auto* page = FindInternalPageByFileUrl(url)) {
|
||||
return InternalUrlForSlug(page->slug) + GetUrlDecoration(url);
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
bool IsInternalHomeUrl(const std::string& url) {
|
||||
return GetUrlWithoutDecoration(ToInternalUrl(url)) == GetHomeUrl();
|
||||
}
|
||||
|
||||
bool IsNebulaInternalUrl(const std::string& url) {
|
||||
return ToLowerAscii(url).starts_with(kNebulaScheme);
|
||||
}
|
||||
|
||||
bool IsHttpUrl(const std::string& url) {
|
||||
return ToLowerAscii(url).starts_with("http://");
|
||||
}
|
||||
|
||||
bool IsChromiumNewTabUrl(const std::string& url) {
|
||||
const std::string target = ToLowerAscii(GetUrlWithoutDecoration(url));
|
||||
return target == "about:blank" ||
|
||||
target == "chrome://newtab" ||
|
||||
target == "chrome://newtab/" ||
|
||||
target == "chrome://new-tab-page" ||
|
||||
target == "chrome://new-tab-page/" ||
|
||||
target == "chrome-search://local-ntp/local-ntp.html";
|
||||
}
|
||||
|
||||
bool IsEmptyOrChromiumNewTabUrl(const std::string& url) {
|
||||
return url.empty() || IsChromiumNewTabUrl(url);
|
||||
}
|
||||
|
||||
} // namespace nebula::ui
|
||||
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
|
||||
namespace nebula::ui {
|
||||
|
||||
std::filesystem::path GetExecutableDirectory();
|
||||
std::filesystem::path GetUserDataDirectory();
|
||||
std::filesystem::path GetCacheDirectory();
|
||||
std::filesystem::path GetSessionStatePath();
|
||||
std::filesystem::path GetUiPagePath(const std::wstring& page_name);
|
||||
std::string FilePathToUrl(std::filesystem::path path);
|
||||
std::string GetChromeUrl();
|
||||
std::string GetHomeUrl();
|
||||
std::string GetSettingsUrl();
|
||||
std::string GetDownloadsUrl();
|
||||
std::string GetBigPictureUrl();
|
||||
std::string GetGpuDiagnosticsUrl();
|
||||
std::string GetMenuPopupUrl();
|
||||
std::string GetInsecureWarningUrl(const std::string& target_url);
|
||||
std::string GetNotFoundUrl(const std::string& target_url);
|
||||
std::string ResolveInternalUrl(const std::string& url);
|
||||
std::string ToInternalUrl(const std::string& url);
|
||||
|
||||
bool IsInternalHomeUrl(const std::string& url);
|
||||
bool IsNebulaInternalUrl(const std::string& url);
|
||||
bool IsHttpUrl(const std::string& url);
|
||||
bool IsChromiumNewTabUrl(const std::string& url);
|
||||
bool IsEmptyOrChromiumNewTabUrl(const std::string& url);
|
||||
|
||||
} // namespace nebula::ui
|
||||
@@ -0,0 +1,538 @@
|
||||
#include "window/nebula_window.h"
|
||||
|
||||
#include <dwmapi.h>
|
||||
#include <windowsx.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace nebula::window {
|
||||
namespace {
|
||||
|
||||
constexpr wchar_t kWindowClassName[] = L"NebulaBrowserWindow";
|
||||
constexpr wchar_t kWindowTitle[] = L"Nebula Browser";
|
||||
constexpr wchar_t kChildFrameHitTestOldProcProp[] = L"NebulaChildFrameHitTestOldProc";
|
||||
constexpr wchar_t kChildFrameHitTestParentProp[] = L"NebulaChildFrameHitTestParent";
|
||||
constexpr int kTitleRowHeightDip = 42;
|
||||
constexpr int kWindowControlWidthDip = 46;
|
||||
constexpr int kWindowControlCount = 3;
|
||||
constexpr COLORREF kNoWindowBorderColor = 0xFFFFFFFE;
|
||||
|
||||
RECT GetWorkArea() {
|
||||
RECT work_area = {};
|
||||
SystemParametersInfoW(SPI_GETWORKAREA, 0, &work_area, 0);
|
||||
return work_area;
|
||||
}
|
||||
|
||||
RECT GetMonitorWorkArea(HWND hwnd) {
|
||||
MONITORINFO monitor_info = {};
|
||||
monitor_info.cbSize = sizeof(monitor_info);
|
||||
|
||||
const HMONITOR monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
|
||||
if (monitor && GetMonitorInfoW(monitor, &monitor_info)) {
|
||||
return monitor_info.rcWork;
|
||||
}
|
||||
|
||||
return GetWorkArea();
|
||||
}
|
||||
|
||||
RECT GetMonitorArea(HWND hwnd) {
|
||||
MONITORINFO monitor_info = {};
|
||||
monitor_info.cbSize = sizeof(monitor_info);
|
||||
|
||||
const HMONITOR monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
|
||||
if (monitor && GetMonitorInfoW(monitor, &monitor_info)) {
|
||||
return monitor_info.rcMonitor;
|
||||
}
|
||||
|
||||
return GetWorkArea();
|
||||
}
|
||||
|
||||
bool IsResizeHit(LRESULT hit) {
|
||||
return hit == HTLEFT || hit == HTRIGHT || hit == HTTOP || hit == HTBOTTOM ||
|
||||
hit == HTTOPLEFT || hit == HTTOPRIGHT || hit == HTBOTTOMLEFT || hit == HTBOTTOMRIGHT;
|
||||
}
|
||||
|
||||
HCURSOR CursorForResizeHit(LRESULT hit) {
|
||||
switch (hit) {
|
||||
case HTLEFT:
|
||||
case HTRIGHT:
|
||||
return LoadCursor(nullptr, IDC_SIZEWE);
|
||||
case HTTOP:
|
||||
case HTBOTTOM:
|
||||
return LoadCursor(nullptr, IDC_SIZENS);
|
||||
case HTTOPLEFT:
|
||||
case HTBOTTOMRIGHT:
|
||||
return LoadCursor(nullptr, IDC_SIZENWSE);
|
||||
case HTTOPRIGHT:
|
||||
case HTBOTTOMLEFT:
|
||||
return LoadCursor(nullptr, IDC_SIZENESW);
|
||||
default:
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
bool SetResizeCursor(LRESULT hit) {
|
||||
HCURSOR cursor = CursorForResizeHit(hit);
|
||||
if (!cursor) {
|
||||
return false;
|
||||
}
|
||||
|
||||
SetCursor(cursor);
|
||||
return true;
|
||||
}
|
||||
|
||||
void ApplyWindowFrameStyle(HWND hwnd) {
|
||||
const BOOL dark_mode = TRUE;
|
||||
const DWM_WINDOW_CORNER_PREFERENCE corner_preference = DWMWCP_ROUND;
|
||||
DwmSetWindowAttribute(
|
||||
hwnd,
|
||||
DWMWA_USE_IMMERSIVE_DARK_MODE,
|
||||
&dark_mode,
|
||||
sizeof(dark_mode));
|
||||
|
||||
DwmSetWindowAttribute(
|
||||
hwnd,
|
||||
DWMWA_WINDOW_CORNER_PREFERENCE,
|
||||
&corner_preference,
|
||||
sizeof(corner_preference));
|
||||
|
||||
DwmSetWindowAttribute(
|
||||
hwnd,
|
||||
DWMWA_BORDER_COLOR,
|
||||
&kNoWindowBorderColor,
|
||||
sizeof(kNoWindowBorderColor));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
NebulaWindow::NebulaWindow(WindowDelegate* delegate) : delegate_(delegate) {}
|
||||
|
||||
NebulaWindow::~NebulaWindow() = default;
|
||||
|
||||
bool NebulaWindow::Create(HINSTANCE instance, int show_command) {
|
||||
instance_ = instance;
|
||||
RegisterClass(instance);
|
||||
|
||||
const RECT work_area = GetWorkArea();
|
||||
dpi_ = GetDpiForSystem();
|
||||
const int width = std::min<LONG>(ScaleForDpi(1400), work_area.right - work_area.left);
|
||||
const int height = std::min<LONG>(ScaleForDpi(900), work_area.bottom - work_area.top);
|
||||
const int x = work_area.left + ((work_area.right - work_area.left) - width) / 2;
|
||||
const int y = work_area.top + ((work_area.bottom - work_area.top) - height) / 2;
|
||||
|
||||
hwnd_ = CreateWindowExW(
|
||||
0,
|
||||
kWindowClassName,
|
||||
kWindowTitle,
|
||||
WS_POPUP | WS_THICKFRAME | WS_MINIMIZEBOX | WS_MAXIMIZEBOX | WS_CLIPCHILDREN,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
nullptr,
|
||||
nullptr,
|
||||
instance_,
|
||||
this);
|
||||
|
||||
if (!hwnd_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
UpdateDpi();
|
||||
ApplyWindowFrameStyle(hwnd_);
|
||||
|
||||
const MARGINS margins = {0, 0, 0, 0};
|
||||
DwmExtendFrameIntoClientArea(hwnd_, &margins);
|
||||
|
||||
ShowWindow(hwnd_, show_command);
|
||||
UpdateWindow(hwnd_);
|
||||
return true;
|
||||
}
|
||||
|
||||
BrowserLayout NebulaWindow::CurrentLayout(bool show_chrome) const {
|
||||
RECT client = {};
|
||||
if (hwnd_) {
|
||||
GetClientRect(hwnd_, &client);
|
||||
}
|
||||
|
||||
BrowserLayout layout;
|
||||
layout.chrome = show_chrome
|
||||
? RECT{0, 0, client.right, std::min<LONG>(ScaleForDpi(chrome_height_dip_), client.bottom)}
|
||||
: RECT{0, 0, 0, 0};
|
||||
layout.content = {0, layout.chrome.bottom, client.right, client.bottom};
|
||||
return layout;
|
||||
}
|
||||
|
||||
void NebulaWindow::ResizeChild(HWND child, const RECT& rect) const {
|
||||
if (!child) {
|
||||
return;
|
||||
}
|
||||
|
||||
EnableFrameHitTest(child);
|
||||
SetWindowPos(
|
||||
child,
|
||||
nullptr,
|
||||
rect.left,
|
||||
rect.top,
|
||||
std::max(0L, rect.right - rect.left),
|
||||
std::max(0L, rect.bottom - rect.top),
|
||||
SWP_NOZORDER | SWP_NOACTIVATE | SWP_NOOWNERZORDER);
|
||||
}
|
||||
|
||||
void NebulaWindow::Minimize() {
|
||||
if (hwnd_) {
|
||||
ShowWindow(hwnd_, SW_MINIMIZE);
|
||||
}
|
||||
}
|
||||
|
||||
void NebulaWindow::ToggleMaximize() {
|
||||
if (!hwnd_ || fullscreen_) {
|
||||
return;
|
||||
}
|
||||
|
||||
ShowWindow(hwnd_, IsZoomed(hwnd_) ? SW_RESTORE : SW_MAXIMIZE);
|
||||
}
|
||||
|
||||
void NebulaWindow::SetFullscreen(bool fullscreen) {
|
||||
if (!hwnd_ || fullscreen_ == fullscreen) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (fullscreen) {
|
||||
restore_style_ = GetWindowLongPtrW(hwnd_, GWL_STYLE);
|
||||
restore_ex_style_ = GetWindowLongPtrW(hwnd_, GWL_EXSTYLE);
|
||||
restore_placement_.length = sizeof(restore_placement_);
|
||||
GetWindowPlacement(hwnd_, &restore_placement_);
|
||||
|
||||
fullscreen_ = true;
|
||||
const RECT monitor = GetMonitorArea(hwnd_);
|
||||
SetWindowLongPtrW(hwnd_, GWL_STYLE, restore_style_ & ~(WS_THICKFRAME | WS_MAXIMIZEBOX | WS_MINIMIZEBOX));
|
||||
SetWindowLongPtrW(hwnd_, GWL_EXSTYLE, restore_ex_style_);
|
||||
SetWindowPos(
|
||||
hwnd_,
|
||||
HWND_TOPMOST,
|
||||
monitor.left,
|
||||
monitor.top,
|
||||
monitor.right - monitor.left,
|
||||
monitor.bottom - monitor.top,
|
||||
SWP_NOOWNERZORDER | SWP_FRAMECHANGED);
|
||||
} else {
|
||||
fullscreen_ = false;
|
||||
SetWindowLongPtrW(hwnd_, GWL_STYLE, restore_style_);
|
||||
SetWindowLongPtrW(hwnd_, GWL_EXSTYLE, restore_ex_style_);
|
||||
SetWindowPlacement(hwnd_, &restore_placement_);
|
||||
SetWindowPos(
|
||||
hwnd_,
|
||||
HWND_NOTOPMOST,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
SWP_NOMOVE | SWP_NOSIZE | SWP_NOOWNERZORDER | SWP_FRAMECHANGED);
|
||||
ApplyWindowFrameStyle(hwnd_);
|
||||
}
|
||||
|
||||
NotifyResize();
|
||||
}
|
||||
|
||||
void NebulaWindow::Close() {
|
||||
if (hwnd_) {
|
||||
SendMessageW(hwnd_, WM_CLOSE, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
void NebulaWindow::BeginDrag() {
|
||||
if (!hwnd_) {
|
||||
return;
|
||||
}
|
||||
|
||||
ReleaseCapture();
|
||||
SendMessageW(hwnd_, WM_NCLBUTTONDOWN, HTCAPTION, 0);
|
||||
}
|
||||
|
||||
void NebulaWindow::SetTitle(const std::wstring& title) {
|
||||
if (hwnd_) {
|
||||
SetWindowTextW(hwnd_, title.empty() ? kWindowTitle : title.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
void NebulaWindow::EnableFrameHitTest(HWND child) const {
|
||||
if (!hwnd_ || !child) {
|
||||
return;
|
||||
}
|
||||
|
||||
EnableFrameHitTestForWindow(child);
|
||||
EnumChildWindows(child, &NebulaWindow::EnableFrameHitTestForDescendant, reinterpret_cast<LPARAM>(this));
|
||||
}
|
||||
|
||||
LRESULT CALLBACK NebulaWindow::StaticWndProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) {
|
||||
NebulaWindow* self = nullptr;
|
||||
|
||||
if (message == WM_NCCREATE) {
|
||||
auto* create = reinterpret_cast<CREATESTRUCTW*>(lparam);
|
||||
self = static_cast<NebulaWindow*>(create->lpCreateParams);
|
||||
SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(self));
|
||||
self->hwnd_ = hwnd;
|
||||
} else {
|
||||
self = reinterpret_cast<NebulaWindow*>(GetWindowLongPtrW(hwnd, GWLP_USERDATA));
|
||||
}
|
||||
|
||||
return self ? self->WndProc(message, wparam, lparam)
|
||||
: DefWindowProcW(hwnd, message, wparam, lparam);
|
||||
}
|
||||
|
||||
LRESULT CALLBACK NebulaWindow::ChildFrameWndProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) {
|
||||
auto old_proc = reinterpret_cast<WNDPROC>(GetPropW(hwnd, kChildFrameHitTestOldProcProp));
|
||||
|
||||
if (message == WM_NCHITTEST) {
|
||||
const auto parent = reinterpret_cast<HWND>(GetPropW(hwnd, kChildFrameHitTestParentProp));
|
||||
auto* self = parent ? reinterpret_cast<NebulaWindow*>(GetWindowLongPtrW(parent, GWLP_USERDATA)) : nullptr;
|
||||
if (self) {
|
||||
const LRESULT hit = self->HitTest(lparam);
|
||||
if (IsResizeHit(hit)) {
|
||||
return hit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (message == WM_SETCURSOR) {
|
||||
const auto parent = reinterpret_cast<HWND>(GetPropW(hwnd, kChildFrameHitTestParentProp));
|
||||
auto* self = parent ? reinterpret_cast<NebulaWindow*>(GetWindowLongPtrW(parent, GWLP_USERDATA)) : nullptr;
|
||||
POINT point = {};
|
||||
if (self && GetCursorPos(&point) && SetResizeCursor(self->HitTestPoint(point))) {
|
||||
return TRUE;
|
||||
}
|
||||
}
|
||||
|
||||
if (message == WM_MOUSEMOVE || message == WM_NCMOUSEMOVE) {
|
||||
const auto parent = reinterpret_cast<HWND>(GetPropW(hwnd, kChildFrameHitTestParentProp));
|
||||
auto* self = parent ? reinterpret_cast<NebulaWindow*>(GetWindowLongPtrW(parent, GWLP_USERDATA)) : nullptr;
|
||||
POINT point = {};
|
||||
if (self && GetCursorPos(&point) && SetResizeCursor(self->HitTestPoint(point))) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (message == WM_NCLBUTTONDOWN && IsResizeHit(static_cast<LRESULT>(wparam))) {
|
||||
const auto parent = reinterpret_cast<HWND>(GetPropW(hwnd, kChildFrameHitTestParentProp));
|
||||
if (parent) {
|
||||
ReleaseCapture();
|
||||
SendMessageW(parent, WM_NCLBUTTONDOWN, wparam, lparam);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (message == WM_NCDESTROY) {
|
||||
RemovePropW(hwnd, kChildFrameHitTestParentProp);
|
||||
RemovePropW(hwnd, kChildFrameHitTestOldProcProp);
|
||||
if (old_proc) {
|
||||
SetWindowLongPtrW(hwnd, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(old_proc));
|
||||
}
|
||||
}
|
||||
|
||||
return old_proc ? CallWindowProcW(old_proc, hwnd, message, wparam, lparam)
|
||||
: DefWindowProcW(hwnd, message, wparam, lparam);
|
||||
}
|
||||
|
||||
BOOL CALLBACK NebulaWindow::EnableFrameHitTestForDescendant(HWND hwnd, LPARAM lparam) {
|
||||
const auto* self = reinterpret_cast<const NebulaWindow*>(lparam);
|
||||
if (self) {
|
||||
self->EnableFrameHitTestForWindow(hwnd);
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
LRESULT NebulaWindow::WndProc(UINT message, WPARAM wparam, LPARAM lparam) {
|
||||
switch (message) {
|
||||
case WM_CREATE:
|
||||
UpdateDpi();
|
||||
if (delegate_) {
|
||||
delegate_->OnWindowCreated();
|
||||
}
|
||||
return 0;
|
||||
|
||||
case WM_NCCALCSIZE:
|
||||
if (wparam == TRUE) {
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
|
||||
case WM_NCACTIVATE:
|
||||
ApplyWindowFrameStyle(hwnd_);
|
||||
return TRUE;
|
||||
|
||||
case WM_ERASEBKGND:
|
||||
return 1;
|
||||
|
||||
case WM_NCHITTEST:
|
||||
return HitTest(lparam);
|
||||
|
||||
case WM_SETCURSOR: {
|
||||
POINT point = {};
|
||||
if (GetCursorPos(&point) && SetResizeCursor(HitTestPoint(point))) {
|
||||
return TRUE;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case WM_MOUSEMOVE:
|
||||
case WM_NCMOUSEMOVE: {
|
||||
POINT point = {};
|
||||
if (GetCursorPos(&point) && SetResizeCursor(HitTestPoint(point))) {
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case WM_SIZE:
|
||||
NotifyResize();
|
||||
return 0;
|
||||
|
||||
case WM_DPICHANGED: {
|
||||
dpi_ = HIWORD(wparam);
|
||||
const auto* suggested_rect = reinterpret_cast<RECT*>(lparam);
|
||||
SetWindowPos(
|
||||
hwnd_,
|
||||
nullptr,
|
||||
suggested_rect->left,
|
||||
suggested_rect->top,
|
||||
suggested_rect->right - suggested_rect->left,
|
||||
suggested_rect->bottom - suggested_rect->top,
|
||||
SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
NotifyResize();
|
||||
return 0;
|
||||
}
|
||||
|
||||
case WM_GETMINMAXINFO: {
|
||||
const RECT work_area = GetMonitorWorkArea(hwnd_);
|
||||
const RECT monitor_area = GetMonitorArea(hwnd_);
|
||||
|
||||
auto* minmax = reinterpret_cast<MINMAXINFO*>(lparam);
|
||||
minmax->ptMaxPosition.x = work_area.left - monitor_area.left;
|
||||
minmax->ptMaxPosition.y = work_area.top - monitor_area.top;
|
||||
minmax->ptMaxSize.x = work_area.right - work_area.left;
|
||||
minmax->ptMaxSize.y = work_area.bottom - work_area.top;
|
||||
return 0;
|
||||
}
|
||||
|
||||
case WM_CLOSE:
|
||||
if (delegate_) {
|
||||
delegate_->OnWindowCloseRequested();
|
||||
return 0;
|
||||
}
|
||||
break;
|
||||
|
||||
case WM_DESTROY:
|
||||
hwnd_ = nullptr;
|
||||
return 0;
|
||||
}
|
||||
|
||||
return DefWindowProcW(hwnd_, message, wparam, lparam);
|
||||
}
|
||||
|
||||
void NebulaWindow::RegisterClass(HINSTANCE instance) {
|
||||
WNDCLASSEXW window_class = {};
|
||||
window_class.cbSize = sizeof(window_class);
|
||||
window_class.lpfnWndProc = StaticWndProc;
|
||||
window_class.hInstance = instance;
|
||||
window_class.hCursor = LoadCursor(nullptr, IDC_ARROW);
|
||||
window_class.lpszClassName = kWindowClassName;
|
||||
|
||||
RegisterClassExW(&window_class);
|
||||
}
|
||||
|
||||
void NebulaWindow::NotifyResize() {
|
||||
if (delegate_) {
|
||||
delegate_->OnWindowResized(CurrentLayout());
|
||||
}
|
||||
}
|
||||
|
||||
void NebulaWindow::EnableFrameHitTestForWindow(HWND child) const {
|
||||
if (!child || GetPropW(child, kChildFrameHitTestOldProcProp)) {
|
||||
return;
|
||||
}
|
||||
|
||||
SetPropW(child, kChildFrameHitTestParentProp, hwnd_);
|
||||
const auto old_proc = reinterpret_cast<WNDPROC>(
|
||||
SetWindowLongPtrW(child, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(&NebulaWindow::ChildFrameWndProc)));
|
||||
if (old_proc) {
|
||||
SetPropW(child, kChildFrameHitTestOldProcProp, reinterpret_cast<HANDLE>(old_proc));
|
||||
} else {
|
||||
RemovePropW(child, kChildFrameHitTestParentProp);
|
||||
}
|
||||
}
|
||||
|
||||
int NebulaWindow::ScaleForDpi(int value) const {
|
||||
return MulDiv(value, static_cast<int>(dpi_), 96);
|
||||
}
|
||||
|
||||
void NebulaWindow::UpdateDpi() {
|
||||
if (hwnd_) {
|
||||
dpi_ = GetDpiForWindow(hwnd_);
|
||||
}
|
||||
}
|
||||
|
||||
LRESULT NebulaWindow::HitTest(LPARAM lparam) const {
|
||||
if (!hwnd_) {
|
||||
return HTNOWHERE;
|
||||
}
|
||||
|
||||
POINT point = {GET_X_LPARAM(lparam), GET_Y_LPARAM(lparam)};
|
||||
return HitTestPoint(point);
|
||||
}
|
||||
|
||||
LRESULT NebulaWindow::HitTestPoint(POINT point) const {
|
||||
if (!hwnd_) {
|
||||
return HTNOWHERE;
|
||||
}
|
||||
|
||||
RECT window = {};
|
||||
GetWindowRect(hwnd_, &window);
|
||||
|
||||
if (fullscreen_ || IsZoomed(hwnd_)) {
|
||||
return HTCLIENT;
|
||||
}
|
||||
|
||||
const int resize_border = ScaleForDpi(resize_border_dip_);
|
||||
const bool left = point.x >= window.left && point.x < window.left + resize_border;
|
||||
const bool right = point.x < window.right && point.x >= window.right - resize_border;
|
||||
const bool top = point.y >= window.top && point.y < window.top + resize_border;
|
||||
const bool bottom = point.y < window.bottom && point.y >= window.bottom - resize_border;
|
||||
|
||||
if (top && left) {
|
||||
return HTTOPLEFT;
|
||||
}
|
||||
if (top && right) {
|
||||
return HTTOPRIGHT;
|
||||
}
|
||||
if (bottom && left) {
|
||||
return HTBOTTOMLEFT;
|
||||
}
|
||||
if (bottom && right) {
|
||||
return HTBOTTOMRIGHT;
|
||||
}
|
||||
if (left) {
|
||||
return HTLEFT;
|
||||
}
|
||||
if (right) {
|
||||
return HTRIGHT;
|
||||
}
|
||||
if (top) {
|
||||
return HTTOP;
|
||||
}
|
||||
if (bottom) {
|
||||
return HTBOTTOM;
|
||||
}
|
||||
|
||||
const int controls_width = ScaleForDpi(kWindowControlWidthDip * kWindowControlCount);
|
||||
const int controls_height = ScaleForDpi(kTitleRowHeightDip);
|
||||
const bool window_controls = point.x >= window.right - controls_width && point.x < window.right &&
|
||||
point.y >= window.top && point.y < window.top + controls_height;
|
||||
if (window_controls) {
|
||||
return HTCLIENT;
|
||||
}
|
||||
|
||||
return HTCLIENT;
|
||||
}
|
||||
|
||||
} // namespace nebula::window
|
||||
@@ -0,0 +1,66 @@
|
||||
#pragma once
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace nebula::window {
|
||||
|
||||
struct BrowserLayout {
|
||||
RECT chrome = {};
|
||||
RECT content = {};
|
||||
};
|
||||
|
||||
class WindowDelegate {
|
||||
public:
|
||||
virtual ~WindowDelegate() = default;
|
||||
virtual void OnWindowCreated() = 0;
|
||||
virtual void OnWindowResized(const BrowserLayout& layout) = 0;
|
||||
virtual void OnWindowCloseRequested() = 0;
|
||||
};
|
||||
|
||||
class NebulaWindow {
|
||||
public:
|
||||
explicit NebulaWindow(WindowDelegate* delegate);
|
||||
~NebulaWindow();
|
||||
|
||||
bool Create(HINSTANCE instance, int show_command);
|
||||
HWND hwnd() const { return hwnd_; }
|
||||
BrowserLayout CurrentLayout(bool show_chrome = true) const;
|
||||
|
||||
void ResizeChild(HWND child, const RECT& rect) const;
|
||||
void Minimize();
|
||||
void ToggleMaximize();
|
||||
void SetFullscreen(bool fullscreen);
|
||||
void Close();
|
||||
void BeginDrag();
|
||||
void SetTitle(const std::wstring& title);
|
||||
void EnableFrameHitTest(HWND child) const;
|
||||
|
||||
private:
|
||||
static LRESULT CALLBACK StaticWndProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam);
|
||||
static LRESULT CALLBACK ChildFrameWndProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam);
|
||||
static BOOL CALLBACK EnableFrameHitTestForDescendant(HWND hwnd, LPARAM lparam);
|
||||
LRESULT WndProc(UINT message, WPARAM wparam, LPARAM lparam);
|
||||
|
||||
void RegisterClass(HINSTANCE instance);
|
||||
void NotifyResize();
|
||||
void EnableFrameHitTestForWindow(HWND child) const;
|
||||
LRESULT HitTest(LPARAM lparam) const;
|
||||
LRESULT HitTestPoint(POINT point) const;
|
||||
int ScaleForDpi(int value) const;
|
||||
void UpdateDpi();
|
||||
|
||||
WindowDelegate* delegate_ = nullptr;
|
||||
HINSTANCE instance_ = nullptr;
|
||||
HWND hwnd_ = nullptr;
|
||||
bool fullscreen_ = false;
|
||||
LONG_PTR restore_style_ = 0;
|
||||
LONG_PTR restore_ex_style_ = 0;
|
||||
WINDOWPLACEMENT restore_placement_ = {sizeof(WINDOWPLACEMENT)};
|
||||
UINT dpi_ = 96;
|
||||
int resize_border_dip_ = 8;
|
||||
int chrome_height_dip_ = 104;
|
||||
};
|
||||
|
||||
} // namespace nebula::window
|
||||
@@ -1,48 +0,0 @@
|
||||
@echo off
|
||||
echo Starting Nebula Browser with GPU diagnostics...
|
||||
echo.
|
||||
|
||||
REM Check if running with administrator privileges
|
||||
net session >nul 2>&1
|
||||
if %errorLevel% neq 0 (
|
||||
echo Warning: Not running as administrator. Some GPU features may not work.
|
||||
echo Consider running as administrator if you encounter GPU issues.
|
||||
echo.
|
||||
)
|
||||
|
||||
REM Set environment variables for better GPU support
|
||||
set ELECTRON_ENABLE_LOGGING=1
|
||||
set ELECTRON_LOG_FILE=gpu-debug.log
|
||||
|
||||
REM Alternative GPU configurations
|
||||
echo Choose GPU configuration:
|
||||
echo 1. Default (recommended)
|
||||
echo 2. Force GPU acceleration
|
||||
echo 3. Disable GPU (software rendering)
|
||||
echo 4. Debug mode with verbose logging
|
||||
echo.
|
||||
set /p choice="Enter choice (1-4): "
|
||||
|
||||
if "%choice%"=="1" (
|
||||
echo Starting with default GPU configuration...
|
||||
npm start
|
||||
) else if "%choice%"=="2" (
|
||||
echo Forcing GPU acceleration...
|
||||
set ELECTRON_FORCE_HARDWARE_ACCELERATION=1
|
||||
npm start
|
||||
) else if "%choice%"=="3" (
|
||||
echo Using software rendering...
|
||||
set ELECTRON_DISABLE_GPU=1
|
||||
npm start -- --disable-gpu
|
||||
) else if "%choice%"=="4" (
|
||||
echo Starting in debug mode...
|
||||
set ELECTRON_ENABLE_STACK_DUMPING=1
|
||||
npm start -- --enable-logging --log-level=0 --vmodule=gpu*=3
|
||||
) else (
|
||||
echo Invalid choice, using default...
|
||||
npm start
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Browser closed. Check gpu-debug.log for GPU-related messages.
|
||||
pause
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Start Nebula Browser with GPU acceleration enabled on Linux
|
||||
cd "$(dirname "$0")"
|
||||
NEBULA_GPU_ALLOW_LINUX=1 npm start
|
||||
@@ -1,66 +0,0 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# NEBULA BROWSER - Steam Deck / SteamOS Launch Script
|
||||
# =============================================================================
|
||||
# This script is designed for launching Nebula on Steam Deck in Game Mode.
|
||||
# It sets necessary environment variables to disable Steam's mouse/keyboard
|
||||
# emulation so the app's native controller support works properly.
|
||||
#
|
||||
# Usage:
|
||||
# 1. Add Nebula as a Non-Steam game (or via Steamworks)
|
||||
# 2. Set launch options to: ./start-steamdeck.sh
|
||||
# OR
|
||||
# 3. Use this script's environment variables in Steam Launch Options:
|
||||
# SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD=0 %command% --big-picture
|
||||
# =============================================================================
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# =============================================================================
|
||||
# STEAM INPUT CONFIGURATION
|
||||
# =============================================================================
|
||||
# These variables tell Steam's input layer that this app handles controller
|
||||
# input natively and should NOT have mouse/keyboard emulation applied.
|
||||
|
||||
# Disable Steam's virtual gamepad layer
|
||||
export SDL_GAMECONTROLLER_ALLOW_STEAM_VIRTUAL_GAMEPAD=0
|
||||
export STEAM_INPUT_ENABLE_VIRTUAL_GAMEPAD=0
|
||||
|
||||
# Allow raw gamepad access
|
||||
export SDL_GAMECONTROLLER_IGNORE_DEVICES=""
|
||||
|
||||
# Allow background gamepad events (useful when app doesn't have focus)
|
||||
export SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS=1
|
||||
|
||||
# Hint to SDL that we're using gamepads natively
|
||||
export SDL_GAMECONTROLLERCONFIG="${SDL_GAMECONTROLLERCONFIG:-}"
|
||||
|
||||
# =============================================================================
|
||||
# NEBULA CONFIGURATION
|
||||
# =============================================================================
|
||||
|
||||
# Enable Big Picture Mode for controller-friendly UI
|
||||
export NEBULA_BIG_PICTURE=1
|
||||
|
||||
# Enable GPU acceleration on Linux
|
||||
export NEBULA_GPU_ALLOW_LINUX=1
|
||||
|
||||
# =============================================================================
|
||||
# LAUNCH
|
||||
# =============================================================================
|
||||
|
||||
# Check if we're in an AppImage/AppDir or dev environment
|
||||
if [ -f "./nebula" ]; then
|
||||
# Packaged AppDir
|
||||
exec ./nebula --big-picture "$@"
|
||||
elif [ -f "./Nebula" ]; then
|
||||
# Alternate launcher name
|
||||
exec ./Nebula --big-picture "$@"
|
||||
elif command -v npm &> /dev/null && [ -f "package.json" ]; then
|
||||
# Development environment
|
||||
npm start -- --big-picture "$@"
|
||||
else
|
||||
echo "Error: Could not find Nebula executable or npm"
|
||||
echo "Make sure you're running this script from the Nebula directory"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,257 +0,0 @@
|
||||
/**
|
||||
* Theme Manager for Nebula Browser
|
||||
* Handles theme loading, saving, and management at the application level
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { app } = require('electron');
|
||||
|
||||
class ThemeManager {
|
||||
constructor() {
|
||||
this.themesDir = path.join(__dirname, 'themes');
|
||||
this.userDataThemesDir = path.join(app.getPath('userData'), 'themes');
|
||||
this.userThemesDir = path.join(this.userDataThemesDir, 'user');
|
||||
this.downloadedThemesDir = path.join(this.userDataThemesDir, 'downloaded');
|
||||
this.legacyUserThemesDir = path.join(this.themesDir, 'user');
|
||||
this.legacyDownloadedThemesDir = path.join(this.themesDir, 'downloaded');
|
||||
|
||||
this.ensureDirectories();
|
||||
this.migrateLegacyThemes();
|
||||
}
|
||||
|
||||
ensureDirectories() {
|
||||
[this.userDataThemesDir, this.userThemesDir, this.downloadedThemesDir].forEach(dir => {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
migrateLegacyThemes() {
|
||||
this.migrateDirectoryIfNeeded(this.legacyUserThemesDir, this.userThemesDir);
|
||||
this.migrateDirectoryIfNeeded(this.legacyDownloadedThemesDir, this.downloadedThemesDir);
|
||||
}
|
||||
|
||||
migrateDirectoryIfNeeded(fromDir, toDir) {
|
||||
try {
|
||||
if (!fs.existsSync(fromDir)) return;
|
||||
if (!fs.existsSync(toDir)) fs.mkdirSync(toDir, { recursive: true });
|
||||
|
||||
const toFiles = fs.readdirSync(toDir).filter(file => file.endsWith('.json'));
|
||||
if (toFiles.length > 0) return;
|
||||
|
||||
const fromFiles = fs.readdirSync(fromDir).filter(file => file.endsWith('.json'));
|
||||
fromFiles.forEach(file => {
|
||||
const sourcePath = path.join(fromDir, file);
|
||||
const destinationPath = path.join(toDir, file);
|
||||
if (!fs.existsSync(destinationPath)) {
|
||||
fs.copyFileSync(sourcePath, destinationPath);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[Themes] Failed to migrate legacy themes:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available themes
|
||||
* @returns {Object} Object containing default, user, and downloaded themes
|
||||
*/
|
||||
getAllThemes() {
|
||||
const themes = {
|
||||
default: this.loadDefaultThemes(),
|
||||
user: this.loadUserThemes(),
|
||||
downloaded: this.loadDownloadedThemes()
|
||||
};
|
||||
|
||||
return themes;
|
||||
}
|
||||
|
||||
loadDefaultThemes() {
|
||||
const defaultThemes = {};
|
||||
const defaultFiles = [
|
||||
'default.json',
|
||||
'ocean.json',
|
||||
'forest.json',
|
||||
'sunset.json',
|
||||
'cyberpunk.json',
|
||||
'midnight-rose.json',
|
||||
'arctic-ice.json',
|
||||
'cherry-blossom.json',
|
||||
'cosmic-purple.json',
|
||||
'emerald-dream.json',
|
||||
'mocha-coffee.json',
|
||||
'lavender-fields.json'
|
||||
];
|
||||
|
||||
defaultFiles.forEach(file => {
|
||||
try {
|
||||
const themePath = path.join(this.themesDir, file);
|
||||
if (fs.existsSync(themePath)) {
|
||||
const themeData = JSON.parse(fs.readFileSync(themePath, 'utf8'));
|
||||
const themeName = path.basename(file, '.json');
|
||||
defaultThemes[themeName] = themeData;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error loading default theme ${file}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
return defaultThemes;
|
||||
}
|
||||
|
||||
loadUserThemes() {
|
||||
return this.loadThemesFromDirectory(this.userThemesDir);
|
||||
}
|
||||
|
||||
loadDownloadedThemes() {
|
||||
return this.loadThemesFromDirectory(this.downloadedThemesDir);
|
||||
}
|
||||
|
||||
loadThemesFromDirectory(directory) {
|
||||
const themes = {};
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(directory)) {
|
||||
return themes;
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(directory).filter(file => file.endsWith('.json'));
|
||||
|
||||
files.forEach(file => {
|
||||
try {
|
||||
const themePath = path.join(directory, file);
|
||||
const themeData = JSON.parse(fs.readFileSync(themePath, 'utf8'));
|
||||
const themeName = path.basename(file, '.json');
|
||||
themes[themeName] = themeData;
|
||||
} catch (error) {
|
||||
console.error(`Error loading theme ${file}:`, error);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error reading themes directory ${directory}:`, error);
|
||||
}
|
||||
|
||||
return themes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a user theme
|
||||
* @param {string} name - Theme name
|
||||
* @param {Object} themeData - Theme data
|
||||
* @returns {boolean} Success status
|
||||
*/
|
||||
saveUserTheme(name, themeData) {
|
||||
try {
|
||||
const filename = name.toLowerCase().replace(/[^a-z0-9]/g, '-') + '.json';
|
||||
const filepath = path.join(this.userThemesDir, filename);
|
||||
|
||||
const themeWithMetadata = {
|
||||
...themeData,
|
||||
name: name,
|
||||
createdAt: new Date().toISOString(),
|
||||
type: 'user'
|
||||
};
|
||||
|
||||
fs.writeFileSync(filepath, JSON.stringify(themeWithMetadata, null, 2));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error saving user theme:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user theme
|
||||
* @param {string} filename - Theme filename
|
||||
* @returns {boolean} Success status
|
||||
*/
|
||||
deleteUserTheme(filename) {
|
||||
try {
|
||||
const filepath = path.join(this.userThemesDir, filename);
|
||||
if (fs.existsSync(filepath)) {
|
||||
fs.unlinkSync(filepath);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Error deleting user theme:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a theme file to downloaded themes
|
||||
* @param {string} sourceFile - Source file path
|
||||
* @returns {boolean} Success status
|
||||
*/
|
||||
importTheme(sourceFile) {
|
||||
try {
|
||||
const themeData = JSON.parse(fs.readFileSync(sourceFile, 'utf8'));
|
||||
|
||||
// Validate theme structure
|
||||
if (!this.validateTheme(themeData)) {
|
||||
throw new Error('Invalid theme structure');
|
||||
}
|
||||
|
||||
const filename = (themeData.name || 'imported-theme')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '-') + '.json';
|
||||
|
||||
const destinationPath = path.join(this.downloadedThemesDir, filename);
|
||||
|
||||
const themeWithMetadata = {
|
||||
...themeData,
|
||||
importedAt: new Date().toISOString(),
|
||||
type: 'downloaded'
|
||||
};
|
||||
|
||||
fs.writeFileSync(destinationPath, JSON.stringify(themeWithMetadata, null, 2));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error importing theme:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate theme structure
|
||||
* @param {Object} theme - Theme object to validate
|
||||
* @returns {boolean} Is valid
|
||||
*/
|
||||
validateTheme(theme) {
|
||||
return theme &&
|
||||
theme.colors &&
|
||||
theme.colors.bg &&
|
||||
theme.colors.primary &&
|
||||
theme.colors.accent &&
|
||||
theme.colors.text &&
|
||||
typeof theme.showLogo === 'boolean' &&
|
||||
theme.layout &&
|
||||
theme.customTitle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get theme by name and type
|
||||
* @param {string} name - Theme name
|
||||
* @param {string} type - Theme type (default, user, downloaded)
|
||||
* @returns {Object|null} Theme data or null
|
||||
*/
|
||||
getTheme(name, type = 'default') {
|
||||
const themes = this.getAllThemes();
|
||||
return themes[type] && themes[type][name] ? themes[type][name] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply theme to application (for use in main process)
|
||||
* @param {Object} theme - Theme to apply
|
||||
*/
|
||||
applyThemeToApp(theme) {
|
||||
// This would be used to apply themes at the application level
|
||||
// For example, updating window chrome colors, etc.
|
||||
console.log('Applying theme to application:', theme.name);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ThemeManager;
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"name": "Arctic Ice",
|
||||
"colors": {
|
||||
"bg": "#f0f8ff",
|
||||
"darkBlue": "#e6f3ff",
|
||||
"darkPurple": "#d1e7ff",
|
||||
"primary": "#4169e1",
|
||||
"accent": "#87ceeb",
|
||||
"text": "#2f4f4f",
|
||||
"urlBarBg": "#e6f3ff",
|
||||
"urlBarText": "#2f4f4f",
|
||||
"urlBarBorder": "#4169e1",
|
||||
"tabBg": "#e6f3ff",
|
||||
"tabText": "#4169e1",
|
||||
"tabActive": "#d1e7ff",
|
||||
"tabActiveText": "#2f4f4f",
|
||||
"tabBorder": "#f0f8ff"
|
||||
},
|
||||
"layout": "centered",
|
||||
"showLogo": true,
|
||||
"customTitle": "Nebula Browser",
|
||||
"gradient": "linear-gradient(145deg, #f0f8ff 0%, #d1e7ff 100%)",
|
||||
"version": "1.0",
|
||||
"description": "A clean, cool theme inspired by arctic ice with crisp blue tones"
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"name": "Cherry Blossom",
|
||||
"colors": {
|
||||
"bg": "#fff5f8",
|
||||
"darkBlue": "#ffe4e8",
|
||||
"darkPurple": "#ffd4db",
|
||||
"primary": "#ff69b4",
|
||||
"accent": "#ffb6c1",
|
||||
"text": "#8b4513",
|
||||
"urlBarBg": "#ffe4e8",
|
||||
"urlBarText": "#8b4513",
|
||||
"urlBarBorder": "#ff69b4",
|
||||
"tabBg": "#ffe4e8",
|
||||
"tabText": "#ff69b4",
|
||||
"tabActive": "#ffd4db",
|
||||
"tabActiveText": "#8b4513",
|
||||
"tabBorder": "#fff5f8"
|
||||
},
|
||||
"layout": "centered",
|
||||
"showLogo": true,
|
||||
"customTitle": "Nebula Browser",
|
||||
"gradient": "linear-gradient(145deg, #fff5f8 0%, #ffd4db 100%)",
|
||||
"version": "1.0",
|
||||
"description": "A soft, warm theme inspired by cherry blossoms with gentle pink tones"
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"name": "Cosmic Purple",
|
||||
"colors": {
|
||||
"bg": "#0f0524",
|
||||
"darkBlue": "#1a0b3d",
|
||||
"darkPurple": "#2d1b69",
|
||||
"primary": "#8a2be2",
|
||||
"accent": "#da70d6",
|
||||
"text": "#e6e6fa",
|
||||
"urlBarBg": "#1a0b3d",
|
||||
"urlBarText": "#e6e6fa",
|
||||
"urlBarBorder": "#8a2be2",
|
||||
"tabBg": "#1a0b3d",
|
||||
"tabText": "#da70d6",
|
||||
"tabActive": "#2d1b69",
|
||||
"tabActiveText": "#e6e6fa",
|
||||
"tabBorder": "#0f0524"
|
||||
},
|
||||
"layout": "centered",
|
||||
"showLogo": true,
|
||||
"customTitle": "Nebula Browser",
|
||||
"gradient": "linear-gradient(145deg, #0f0524 0%, #2d1b69 50%, #4b0082 100%)",
|
||||
"version": "1.0",
|
||||
"description": "A space-inspired theme with deep cosmic purples and starlight accents"
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"name": "Cyberpunk Neon",
|
||||
"colors": {
|
||||
"bg": "#0a0a0a",
|
||||
"darkBlue": "#1a0520",
|
||||
"darkPurple": "#2a0a3a",
|
||||
"primary": "#ff0080",
|
||||
"accent": "#00ffff",
|
||||
"text": "#ffffff",
|
||||
"urlBarBg": "#1a0520",
|
||||
"urlBarText": "#ffffff",
|
||||
"urlBarBorder": "#ff0080",
|
||||
"tabBg": "#1a0520",
|
||||
"tabText": "#00ffff",
|
||||
"tabActive": "#2a0a3a",
|
||||
"tabActiveText": "#ff0080",
|
||||
"tabBorder": "#ff0080"
|
||||
},
|
||||
"layout": "centered",
|
||||
"showLogo": true,
|
||||
"customTitle": "Nebula Browser",
|
||||
"gradient": "linear-gradient(145deg, #0a0a0a 0%, #2a0a3a 50%, #1a0520 100%)",
|
||||
"version": "1.0",
|
||||
"description": "A futuristic cyberpunk theme with neon pink and cyan highlights"
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"name": "Default Nebula",
|
||||
"colors": {
|
||||
"bg": "#121418",
|
||||
"darkBlue": "#0B1C2B",
|
||||
"darkPurple": "#1B1035",
|
||||
"primary": "#7B2EFF",
|
||||
"accent": "#00C6FF",
|
||||
"text": "#E0E0E0",
|
||||
"urlBarBg": "#1C2030",
|
||||
"urlBarText": "#E0E0E0",
|
||||
"urlBarBorder": "#3E4652",
|
||||
"tabBg": "#161925",
|
||||
"tabText": "#A4A7B3",
|
||||
"tabActive": "#1C2030",
|
||||
"tabActiveText": "#E0E0E0",
|
||||
"tabBorder": "#2B3040"
|
||||
},
|
||||
"layout": "centered",
|
||||
"showLogo": true,
|
||||
"customTitle": "Nebula Browser",
|
||||
"gradient": "linear-gradient(145deg, #121418 0%, #1B1035 100%)",
|
||||
"version": "1.0",
|
||||
"description": "The original Nebula Browser theme with purple and blue gradients"
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"name": "Emerald Dream",
|
||||
"colors": {
|
||||
"bg": "#0d2818",
|
||||
"darkBlue": "#1a3a2e",
|
||||
"darkPurple": "#2d5a44",
|
||||
"primary": "#50c878",
|
||||
"accent": "#98fb98",
|
||||
"text": "#f0fff0",
|
||||
"urlBarBg": "#1a3a2e",
|
||||
"urlBarText": "#f0fff0",
|
||||
"urlBarBorder": "#50c878",
|
||||
"tabBg": "#1a3a2e",
|
||||
"tabText": "#98fb98",
|
||||
"tabActive": "#2d5a44",
|
||||
"tabActiveText": "#f0fff0",
|
||||
"tabBorder": "#0d2818"
|
||||
},
|
||||
"layout": "centered",
|
||||
"showLogo": true,
|
||||
"customTitle": "Nebula Browser",
|
||||
"gradient": "linear-gradient(145deg, #0d2818 0%, #2d5a44 100%)",
|
||||
"version": "1.0",
|
||||
"description": "A sophisticated theme with rich emerald greens and jewel-like accents"
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"name": "Forest Night",
|
||||
"colors": {
|
||||
"bg": "#1a202c",
|
||||
"darkBlue": "#2d3748",
|
||||
"darkPurple": "#4a5568",
|
||||
"primary": "#68d391",
|
||||
"accent": "#9ae6b4",
|
||||
"text": "#f7fafc",
|
||||
"urlBarBg": "#2d3748",
|
||||
"urlBarText": "#f7fafc",
|
||||
"urlBarBorder": "#4a5568",
|
||||
"tabBg": "#2d3748",
|
||||
"tabText": "#cbd5e0",
|
||||
"tabActive": "#4a5568",
|
||||
"tabActiveText": "#f7fafc",
|
||||
"tabBorder": "#1a202c"
|
||||
},
|
||||
"layout": "centered",
|
||||
"showLogo": true,
|
||||
"customTitle": "Nebula Browser",
|
||||
"gradient": "linear-gradient(145deg, #1a202c 0%, #2d3748 100%)",
|
||||
"version": "1.0",
|
||||
"description": "A nature-inspired theme with forest greens and earth tones"
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"name": "Lavender Fields",
|
||||
"colors": {
|
||||
"bg": "#f8f4ff",
|
||||
"darkBlue": "#ede4ff",
|
||||
"darkPurple": "#e6d8ff",
|
||||
"primary": "#9370db",
|
||||
"accent": "#dda0dd",
|
||||
"text": "#4b0082",
|
||||
"urlBarBg": "#ede4ff",
|
||||
"urlBarText": "#4b0082",
|
||||
"urlBarBorder": "#9370db",
|
||||
"tabBg": "#ede4ff",
|
||||
"tabText": "#9370db",
|
||||
"tabActive": "#e6d8ff",
|
||||
"tabActiveText": "#4b0082",
|
||||
"tabBorder": "#f8f4ff"
|
||||
},
|
||||
"layout": "centered",
|
||||
"showLogo": true,
|
||||
"customTitle": "Nebula Browser",
|
||||
"gradient": "linear-gradient(145deg, #f8f4ff 0%, #e6d8ff 100%)",
|
||||
"version": "1.0",
|
||||
"description": "A peaceful light theme inspired by lavender fields with soft purple hues"
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"name": "Midnight Rose",
|
||||
"colors": {
|
||||
"bg": "#1c1820",
|
||||
"darkBlue": "#2d2433",
|
||||
"darkPurple": "#3d3046",
|
||||
"primary": "#d4af37",
|
||||
"accent": "#ffd700",
|
||||
"text": "#f5f5dc",
|
||||
"urlBarBg": "#3d3046",
|
||||
"urlBarText": "#f5f5dc",
|
||||
"urlBarBorder": "#d4af37",
|
||||
"tabBg": "#2d2433",
|
||||
"tabText": "#d4af37",
|
||||
"tabActive": "#3d3046",
|
||||
"tabActiveText": "#ffd700",
|
||||
"tabBorder": "#1c1820"
|
||||
},
|
||||
"layout": "centered",
|
||||
"showLogo": true,
|
||||
"customTitle": "Nebula Browser",
|
||||
"gradient": "linear-gradient(145deg, #1c1820 0%, #3d3046 100%)",
|
||||
"version": "1.0",
|
||||
"description": "An elegant dark theme with rose gold accents and warm undertones"
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"name": "Mocha Coffee",
|
||||
"colors": {
|
||||
"bg": "#3c2414",
|
||||
"darkBlue": "#4a2c1a",
|
||||
"darkPurple": "#5d3a26",
|
||||
"primary": "#d2691e",
|
||||
"accent": "#daa520",
|
||||
"text": "#faf0e6",
|
||||
"urlBarBg": "#4a2c1a",
|
||||
"urlBarText": "#faf0e6",
|
||||
"urlBarBorder": "#d2691e",
|
||||
"tabBg": "#4a2c1a",
|
||||
"tabText": "#daa520",
|
||||
"tabActive": "#5d3a26",
|
||||
"tabActiveText": "#faf0e6",
|
||||
"tabBorder": "#3c2414"
|
||||
},
|
||||
"layout": "centered",
|
||||
"showLogo": true,
|
||||
"customTitle": "Nebula Browser",
|
||||
"gradient": "linear-gradient(145deg, #3c2414 0%, #5d3a26 100%)",
|
||||
"version": "1.0",
|
||||
"description": "A warm, cozy theme inspired by coffee and chocolate tones"
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"name": "Ocean Depths",
|
||||
"colors": {
|
||||
"bg": "#1a365d",
|
||||
"darkBlue": "#2a4365",
|
||||
"darkPurple": "#2c5282",
|
||||
"primary": "#3182ce",
|
||||
"accent": "#00d9ff",
|
||||
"text": "#e2e8f0",
|
||||
"urlBarBg": "#2d5282",
|
||||
"urlBarText": "#e2e8f0",
|
||||
"urlBarBorder": "#1e3a5f",
|
||||
"tabBg": "#2a4365",
|
||||
"tabText": "#cbd5e0",
|
||||
"tabActive": "#2d5282",
|
||||
"tabActiveText": "#e2e8f0",
|
||||
"tabBorder": "#1a365d"
|
||||
},
|
||||
"layout": "centered",
|
||||
"showLogo": true,
|
||||
"customTitle": "Nebula Browser",
|
||||
"gradient": "linear-gradient(145deg, #1a365d 0%, #2c5282 100%)",
|
||||
"version": "1.0",
|
||||
"description": "A calming ocean-inspired theme with blues and teals"
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"name": "Sunset Glow",
|
||||
"colors": {
|
||||
"bg": "#744210",
|
||||
"darkBlue": "#975a16",
|
||||
"darkPurple": "#c05621",
|
||||
"primary": "#ed8936",
|
||||
"accent": "#fbb040",
|
||||
"text": "#fffaf0",
|
||||
"urlBarBg": "#975a16",
|
||||
"urlBarText": "#fffaf0",
|
||||
"urlBarBorder": "#c05621",
|
||||
"tabBg": "#975a16",
|
||||
"tabText": "#fde4b6",
|
||||
"tabActive": "#c05621",
|
||||
"tabActiveText": "#fffaf0",
|
||||
"tabBorder": "#744210"
|
||||
},
|
||||
"layout": "centered",
|
||||
"showLogo": true,
|
||||
"customTitle": "Nebula Browser",
|
||||
"gradient": "linear-gradient(145deg, #744210 0%, #c05621 100%)",
|
||||
"version": "1.0",
|
||||
"description": "A warm sunset theme with oranges and golden hues"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:xodm="http://www.corel.com/coreldraw/odm/2003" viewBox="0 0 396 537">
|
||||
<!-- Generator: Adobe Illustrator 29.6.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 9) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: url(#linear-gradient);
|
||||
fill-rule: evenodd;
|
||||
}
|
||||
</style>
|
||||
<linearGradient id="linear-gradient" x1="33174.52" y1="-10318.51" x2="32748.82" y2="-30894.15" gradientTransform="translate(-738.46 -250.12) scale(.03 -.03)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#26b8f4"/>
|
||||
<stop offset="1" stop-color="#1b48ef"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g id="Camada_x5F_1">
|
||||
<polygon class="st0" points="18.24 7.68 121.67 44.11 122.02 406.95 266.22 322.9 195.86 289.99 150.71 177.85 379.62 258.02 379.43 375.52 121.65 524 18.58 466.65 18.24 7.68"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 976 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 122.88 122.88"><defs><style>.a{fill:#d53;}.b{fill:#fff;}.c{fill:#ddd;}.d{fill:#fc0;}.e{fill:#6b5;}.f{fill:#4a4;}.g{fill:#148;}</style></defs><title>duckduckgo</title><path class="a" d="M122.88,61.44a61.44,61.44,0,1,0-61.44,61.44,61.44,61.44,0,0,0,61.44-61.44Z"/><path class="b" d="M114.37,61.44a52.92,52.92,0,1,0-15.5,37.43,52.76,52.76,0,0,0,15.5-37.43Zm-13.12-39.8A56.29,56.29,0,1,1,61.44,5.15a56.12,56.12,0,0,1,39.81,16.49Z"/><path class="c" d="M43.24,30.15C26.17,34.13,32.43,58,32.43,58l10.81,52.9,4,1.71-4-82.49Zm-4-10.24H34.7L41,22.19s-6.26,0-6.26,4C48.36,25.6,54.61,29,54.61,29l-15.36-9.1Zm0,0Z"/><path class="b" d="M75.66,115.48S62,93.87,62,79.64c0-26.73,17.63-4,17.63-25S62,28.44,62,28.44c-8.53-10.8-25-8.53-25-8.53l4,2.28s-4,1.13-5.12,2.27,10.81-1.7,15.93,2.85C30.72,29,34.13,46.08,34.13,46.08l11.95,68.27,29.58,1.13Zm0,0Z"/><path class="d" d="M75.66,60.87l21.62-5.69C116.62,58,80.78,68.84,78.51,68.27c-17.07-2.85-12,11.37,8.53,6.82s5.12,11.38-13.65,5.12c-26.74-7.39-12.52-20.48,2.27-19.34Z"/><path class="e" d="M70,105.81l1.14-1.7c12.52,4.55,13.09,6.25,12.52-5.12s0-11.38-13.09-1.71c0-2.84-7.39-1.71-8.53,0-11.95-5.12-13.09-6.83-12.52,1.14,1.14,16.5.57,13.65,11.95,8l8.53-.57Zm0,0Z"/><path class="f" d="M60.87,99.56v6.82c.57,1.14,9.67,1.14,9.67-1.14s-4.55,1.71-7.39.57S62,98.42,62,98.42l-1.14,1.14Zm0,0Z"/><path class="g" d="M48.36,43.24c-2.85-3.42-10.24-.57-8.54,4,.57-2.28,4.55-5.69,8.54-4Zm18.2,0c.57-3.42,6.26-4,8-.57a8,8,0,0,0-8,.57Zm-18.77,9.1a1.14,1.14,0,1,1,0,.57v-.57Zm-4.55,2.27a4,4,0,1,0,0-.57v.57Zm29.58-4a1.14,1.14,0,1,1,0,.57v-.57ZM69.4,52.91a3.42,3.42,0,1,0,0-.57v.57Zm0,0Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="-3 0 262 262" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid"><path d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622 38.755 30.023 2.685.268c24.659-22.774 38.875-56.282 38.875-96.027" fill="#4285F4"/><path d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055-34.523 0-63.824-22.773-74.269-54.25l-1.531.13-40.298 31.187-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1" fill="#34A853"/><path d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82 0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602l42.356-32.782" fill="#FBBC05"/><path d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0 79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251" fill="#EB4335"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |