mirror of
https://github.com/marcoallegretti/WEFT_OS.git
synced 2026-03-27 01:13:09 +00:00
feat(servo-shell): input forwarding, weft-app URL resolution, weftIpc JS bridge (servo-embed only)
This commit is contained in:
parent
aa005dd3e6
commit
b4824aa8d4
4 changed files with 304 additions and 13 deletions
|
|
@ -62,12 +62,12 @@ fn parse_allowed(args: &[String]) -> Vec<PathBuf> {
|
||||||
let mut allowed = Vec::new();
|
let mut allowed = Vec::new();
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
while i < args.len() {
|
while i < args.len() {
|
||||||
if args[i] == "--allow" {
|
if args[i] == "--allow"
|
||||||
if let Some(p) = args.get(i + 1) {
|
&& let Some(p) = args.get(i + 1)
|
||||||
allowed.push(PathBuf::from(p));
|
{
|
||||||
i += 2;
|
allowed.push(PathBuf::from(p));
|
||||||
continue;
|
i += 2;
|
||||||
}
|
continue;
|
||||||
}
|
}
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,19 @@
|
||||||
#![cfg(feature = "servo-embed")]
|
#![cfg(feature = "servo-embed")]
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use servo::{
|
use servo::{
|
||||||
EventLoopWaker, ServoBuilder, ServoDelegate, ServoUrl, WebViewBuilder, WebViewDelegate,
|
EventLoopWaker, InputEvent, MouseButton as ServoMouseButton, MouseButtonAction,
|
||||||
|
MouseButtonEvent, MouseMoveEvent, ServoBuilder, ServoDelegate, ServoUrl, UserContentManager,
|
||||||
|
UserScript, WebViewBuilder, WebViewDelegate,
|
||||||
};
|
};
|
||||||
use winit::{
|
use winit::{
|
||||||
application::ApplicationHandler,
|
application::ApplicationHandler,
|
||||||
event::WindowEvent,
|
event::{ElementState, MouseButton, WindowEvent},
|
||||||
event_loop::{ActiveEventLoop, EventLoop, EventLoopProxy},
|
event_loop::{ActiveEventLoop, EventLoop, EventLoopProxy},
|
||||||
|
keyboard::ModifiersState,
|
||||||
window::{Window, WindowAttributes, WindowId},
|
window::{Window, WindowAttributes, WindowId},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -65,24 +68,30 @@ impl WebViewDelegate for WeftWebViewDelegate {
|
||||||
|
|
||||||
struct App {
|
struct App {
|
||||||
url: ServoUrl,
|
url: ServoUrl,
|
||||||
|
ws_port: u16,
|
||||||
window: Option<Arc<Window>>,
|
window: Option<Arc<Window>>,
|
||||||
servo: Option<servo::Servo>,
|
servo: Option<servo::Servo>,
|
||||||
webview: Option<servo::WebView>,
|
webview: Option<servo::WebView>,
|
||||||
redraw_requested: Arc<std::sync::atomic::AtomicBool>,
|
redraw_requested: Arc<std::sync::atomic::AtomicBool>,
|
||||||
waker: WeftEventLoopWaker,
|
waker: WeftEventLoopWaker,
|
||||||
shutting_down: bool,
|
shutting_down: bool,
|
||||||
|
modifiers: ModifiersState,
|
||||||
|
cursor_pos: servo::euclid::default::Point2D<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
fn new(url: ServoUrl, waker: WeftEventLoopWaker) -> Self {
|
fn new(url: ServoUrl, waker: WeftEventLoopWaker, ws_port: u16) -> Self {
|
||||||
Self {
|
Self {
|
||||||
url,
|
url,
|
||||||
|
ws_port,
|
||||||
window: None,
|
window: None,
|
||||||
servo: None,
|
servo: None,
|
||||||
webview: None,
|
webview: None,
|
||||||
redraw_requested: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
redraw_requested: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||||
waker,
|
waker,
|
||||||
shutting_down: false,
|
shutting_down: false,
|
||||||
|
modifiers: ModifiersState::default(),
|
||||||
|
cursor_pos: servo::euclid::default::Point2D::zero(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,10 +146,18 @@ impl ApplicationHandler<ServoWake> for App {
|
||||||
.expect("SoftwareRenderingContext"),
|
.expect("SoftwareRenderingContext"),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let user_content_manager = Rc::new(UserContentManager::new(&servo));
|
||||||
|
let bridge_js = format!(
|
||||||
|
r#"(function(){{var ws=new WebSocket('ws://127.0.0.1:{p}');var q=[];var r=false;ws.onopen=function(){{r=true;q.forEach(function(m){{ws.send(JSON.stringify(m))}});q.length=0}};window.weftIpc={{send:function(m){{if(r)ws.send(JSON.stringify(m));else q.push(m)}},onmessage:null}};ws.onmessage=function(e){{if(window.weftIpc.onmessage)window.weftIpc.onmessage(JSON.parse(e.data))}}}})()"#,
|
||||||
|
p = self.ws_port
|
||||||
|
);
|
||||||
|
user_content_manager.add_script(Rc::new(UserScript::new(bridge_js, None)));
|
||||||
|
|
||||||
let webview = WebViewBuilder::new(&servo, Rc::clone(&rendering_context))
|
let webview = WebViewBuilder::new(&servo, Rc::clone(&rendering_context))
|
||||||
.delegate(Rc::new(WeftWebViewDelegate {
|
.delegate(Rc::new(WeftWebViewDelegate {
|
||||||
redraw_requested: Arc::clone(&self.redraw_requested),
|
redraw_requested: Arc::clone(&self.redraw_requested),
|
||||||
}))
|
}))
|
||||||
|
.user_content_manager(Rc::clone(&user_content_manager))
|
||||||
.url(self.url.clone())
|
.url(self.url.clone())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
|
@ -193,6 +210,41 @@ impl ApplicationHandler<ServoWake> for App {
|
||||||
wv.resize(servo::euclid::Size2D::new(new_size.width, new_size.height));
|
wv.resize(servo::euclid::Size2D::new(new_size.width, new_size.height));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
WindowEvent::ModifiersChanged(mods) => {
|
||||||
|
self.modifiers = mods.state();
|
||||||
|
}
|
||||||
|
WindowEvent::KeyboardInput { event, .. } => {
|
||||||
|
if let Some(wv) = &self.webview {
|
||||||
|
let ev = super::keyutils::keyboard_event_from_winit(&event, self.modifiers);
|
||||||
|
let _ = wv.notify_input_event(InputEvent::Keyboard(ev));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WindowEvent::CursorMoved { position, .. } => {
|
||||||
|
let pt = servo::euclid::default::Point2D::new(position.x as f32, position.y as f32);
|
||||||
|
self.cursor_pos = pt;
|
||||||
|
if let Some(wv) = &self.webview {
|
||||||
|
let _ = wv.notify_input_event(InputEvent::MouseMove(MouseMoveEvent::new(pt)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WindowEvent::MouseInput { state, button, .. } => {
|
||||||
|
let btn = match button {
|
||||||
|
MouseButton::Left => ServoMouseButton::Left,
|
||||||
|
MouseButton::Right => ServoMouseButton::Right,
|
||||||
|
MouseButton::Middle => ServoMouseButton::Middle,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
let action = match state {
|
||||||
|
ElementState::Pressed => MouseButtonAction::Click,
|
||||||
|
ElementState::Released => MouseButtonAction::Up,
|
||||||
|
};
|
||||||
|
if let Some(wv) = &self.webview {
|
||||||
|
let _ = wv.notify_input_event(InputEvent::MouseButton(MouseButtonEvent::new(
|
||||||
|
action,
|
||||||
|
btn,
|
||||||
|
self.cursor_pos.cast_unit(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
WindowEvent::CloseRequested => {
|
WindowEvent::CloseRequested => {
|
||||||
self.shutting_down = true;
|
self.shutting_down = true;
|
||||||
if let Some(servo) = &self.servo {
|
if let Some(servo) = &self.servo {
|
||||||
|
|
@ -207,10 +259,43 @@ impl ApplicationHandler<ServoWake> for App {
|
||||||
|
|
||||||
// ── Public entry point ────────────────────────────────────────────────────────
|
// ── Public entry point ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub fn run(html_path: &Path, _ws_port: u16) -> anyhow::Result<()> {
|
fn resolve_weft_app_url(url: &ServoUrl) -> Option<ServoUrl> {
|
||||||
|
if url.scheme() != "weft-app" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let app_id = url.host_str()?;
|
||||||
|
let rel = url.path().trim_start_matches('/');
|
||||||
|
let file_path = app_store_roots()
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| r.join(app_id).join("ui").join(rel))
|
||||||
|
.find(|p| p.exists())?;
|
||||||
|
let s = format!("file://{}", file_path.display());
|
||||||
|
ServoUrl::parse(&s).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app_store_roots() -> Vec<PathBuf> {
|
||||||
|
if let Ok(v) = std::env::var("WEFT_APP_STORE") {
|
||||||
|
return vec![PathBuf::from(v)];
|
||||||
|
}
|
||||||
|
let mut roots = Vec::new();
|
||||||
|
if let Ok(home) = std::env::var("HOME") {
|
||||||
|
roots.push(
|
||||||
|
PathBuf::from(home)
|
||||||
|
.join(".local")
|
||||||
|
.join("share")
|
||||||
|
.join("weft")
|
||||||
|
.join("apps"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
roots.push(PathBuf::from("/usr/share/weft/apps"));
|
||||||
|
roots
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(html_path: &Path, ws_port: u16) -> anyhow::Result<()> {
|
||||||
let url_str = format!("file://{}", html_path.display());
|
let url_str = format!("file://{}", html_path.display());
|
||||||
let url =
|
let raw_url =
|
||||||
ServoUrl::parse(&url_str).map_err(|e| anyhow::anyhow!("invalid URL {url_str}: {e}"))?;
|
ServoUrl::parse(&url_str).map_err(|e| anyhow::anyhow!("invalid URL {url_str}: {e}"))?;
|
||||||
|
let url = resolve_weft_app_url(&raw_url).unwrap_or(raw_url);
|
||||||
|
|
||||||
let event_loop = EventLoop::<ServoWake>::with_user_event()
|
let event_loop = EventLoop::<ServoWake>::with_user_event()
|
||||||
.build()
|
.build()
|
||||||
|
|
@ -220,7 +305,7 @@ pub fn run(html_path: &Path, _ws_port: u16) -> anyhow::Result<()> {
|
||||||
proxy: Arc::new(Mutex::new(event_loop.create_proxy())),
|
proxy: Arc::new(Mutex::new(event_loop.create_proxy())),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut app = App::new(url, waker);
|
let mut app = App::new(url, waker, ws_port);
|
||||||
event_loop
|
event_loop
|
||||||
.run_app(&mut app)
|
.run_app(&mut app)
|
||||||
.map_err(|e| anyhow::anyhow!("event loop run: {e}"))
|
.map_err(|e| anyhow::anyhow!("event loop run: {e}"))
|
||||||
|
|
|
||||||
204
crates/weft-servo-shell/src/keyutils.rs
Normal file
204
crates/weft-servo-shell/src/keyutils.rs
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
#![cfg(feature = "servo-embed")]
|
||||||
|
|
||||||
|
use servo::{Code, Key, KeyState, KeyboardEvent, Location, Modifiers};
|
||||||
|
use winit::event::ElementState;
|
||||||
|
use winit::keyboard::{Key as WinitKey, KeyLocation, ModifiersState, NamedKey, PhysicalKey};
|
||||||
|
|
||||||
|
pub fn keyboard_event_from_winit(
|
||||||
|
event: &winit::event::KeyEvent,
|
||||||
|
modifiers: ModifiersState,
|
||||||
|
) -> KeyboardEvent {
|
||||||
|
let state = match event.state {
|
||||||
|
ElementState::Pressed => KeyState::Down,
|
||||||
|
ElementState::Released => KeyState::Up,
|
||||||
|
};
|
||||||
|
|
||||||
|
let key = match &event.logical_key {
|
||||||
|
WinitKey::Named(n) => Key::Named(named_key(*n)),
|
||||||
|
WinitKey::Character(c) => Key::Character(c.to_string().into()),
|
||||||
|
WinitKey::Unidentified(_) => Key::Unidentified,
|
||||||
|
WinitKey::Dead(c) => Key::Dead(*c),
|
||||||
|
};
|
||||||
|
|
||||||
|
let code = match event.physical_key {
|
||||||
|
PhysicalKey::Code(c) => winit_code_to_code(c),
|
||||||
|
PhysicalKey::Unidentified(_) => Code::Unidentified,
|
||||||
|
};
|
||||||
|
|
||||||
|
let location = match event.location {
|
||||||
|
KeyLocation::Standard => Location::Standard,
|
||||||
|
KeyLocation::Left => Location::Left,
|
||||||
|
KeyLocation::Right => Location::Right,
|
||||||
|
KeyLocation::Numpad => Location::Numpad,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut mods = Modifiers::empty();
|
||||||
|
if modifiers.shift_key() {
|
||||||
|
mods |= Modifiers::SHIFT;
|
||||||
|
}
|
||||||
|
if modifiers.control_key() {
|
||||||
|
mods |= Modifiers::CONTROL;
|
||||||
|
}
|
||||||
|
if modifiers.alt_key() {
|
||||||
|
mods |= Modifiers::ALT;
|
||||||
|
}
|
||||||
|
if modifiers.super_key() {
|
||||||
|
mods |= Modifiers::META;
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyboardEvent {
|
||||||
|
state,
|
||||||
|
key,
|
||||||
|
code,
|
||||||
|
location,
|
||||||
|
modifiers: mods,
|
||||||
|
repeat: event.repeat,
|
||||||
|
is_composing: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn named_key(n: NamedKey) -> servo::NamedKey {
|
||||||
|
use servo::NamedKey as S;
|
||||||
|
match n {
|
||||||
|
NamedKey::Alt => S::Alt,
|
||||||
|
NamedKey::AltGraph => S::AltGraph,
|
||||||
|
NamedKey::CapsLock => S::CapsLock,
|
||||||
|
NamedKey::Control => S::Control,
|
||||||
|
NamedKey::Fn => S::Fn,
|
||||||
|
NamedKey::FnLock => S::FnLock,
|
||||||
|
NamedKey::Meta => S::Meta,
|
||||||
|
NamedKey::NumLock => S::NumLock,
|
||||||
|
NamedKey::ScrollLock => S::ScrollLock,
|
||||||
|
NamedKey::Shift => S::Shift,
|
||||||
|
NamedKey::Symbol => S::Symbol,
|
||||||
|
NamedKey::SymbolLock => S::SymbolLock,
|
||||||
|
NamedKey::Enter => S::Enter,
|
||||||
|
NamedKey::Tab => S::Tab,
|
||||||
|
NamedKey::Space => S::Space,
|
||||||
|
NamedKey::ArrowDown => S::ArrowDown,
|
||||||
|
NamedKey::ArrowLeft => S::ArrowLeft,
|
||||||
|
NamedKey::ArrowRight => S::ArrowRight,
|
||||||
|
NamedKey::ArrowUp => S::ArrowUp,
|
||||||
|
NamedKey::End => S::End,
|
||||||
|
NamedKey::Home => S::Home,
|
||||||
|
NamedKey::PageDown => S::PageDown,
|
||||||
|
NamedKey::PageUp => S::PageUp,
|
||||||
|
NamedKey::Backspace => S::Backspace,
|
||||||
|
NamedKey::Clear => S::Clear,
|
||||||
|
NamedKey::Copy => S::Copy,
|
||||||
|
NamedKey::CrSel => S::CrSel,
|
||||||
|
NamedKey::Cut => S::Cut,
|
||||||
|
NamedKey::Delete => S::Delete,
|
||||||
|
NamedKey::EraseEof => S::EraseEof,
|
||||||
|
NamedKey::ExSel => S::ExSel,
|
||||||
|
NamedKey::Insert => S::Insert,
|
||||||
|
NamedKey::Paste => S::Paste,
|
||||||
|
NamedKey::Redo => S::Redo,
|
||||||
|
NamedKey::Undo => S::Undo,
|
||||||
|
NamedKey::Escape => S::Escape,
|
||||||
|
NamedKey::F1 => S::F1,
|
||||||
|
NamedKey::F2 => S::F2,
|
||||||
|
NamedKey::F3 => S::F3,
|
||||||
|
NamedKey::F4 => S::F4,
|
||||||
|
NamedKey::F5 => S::F5,
|
||||||
|
NamedKey::F6 => S::F6,
|
||||||
|
NamedKey::F7 => S::F7,
|
||||||
|
NamedKey::F8 => S::F8,
|
||||||
|
NamedKey::F9 => S::F9,
|
||||||
|
NamedKey::F10 => S::F10,
|
||||||
|
NamedKey::F11 => S::F11,
|
||||||
|
NamedKey::F12 => S::F12,
|
||||||
|
_ => S::Unidentified,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn winit_code_to_code(c: winit::keyboard::KeyCode) -> Code {
|
||||||
|
use winit::keyboard::KeyCode as W;
|
||||||
|
match c {
|
||||||
|
W::Backquote => Code::Backquote,
|
||||||
|
W::Backslash => Code::Backslash,
|
||||||
|
W::BracketLeft => Code::BracketLeft,
|
||||||
|
W::BracketRight => Code::BracketRight,
|
||||||
|
W::Comma => Code::Comma,
|
||||||
|
W::Digit0 => Code::Digit0,
|
||||||
|
W::Digit1 => Code::Digit1,
|
||||||
|
W::Digit2 => Code::Digit2,
|
||||||
|
W::Digit3 => Code::Digit3,
|
||||||
|
W::Digit4 => Code::Digit4,
|
||||||
|
W::Digit5 => Code::Digit5,
|
||||||
|
W::Digit6 => Code::Digit6,
|
||||||
|
W::Digit7 => Code::Digit7,
|
||||||
|
W::Digit8 => Code::Digit8,
|
||||||
|
W::Digit9 => Code::Digit9,
|
||||||
|
W::Equal => Code::Equal,
|
||||||
|
W::KeyA => Code::KeyA,
|
||||||
|
W::KeyB => Code::KeyB,
|
||||||
|
W::KeyC => Code::KeyC,
|
||||||
|
W::KeyD => Code::KeyD,
|
||||||
|
W::KeyE => Code::KeyE,
|
||||||
|
W::KeyF => Code::KeyF,
|
||||||
|
W::KeyG => Code::KeyG,
|
||||||
|
W::KeyH => Code::KeyH,
|
||||||
|
W::KeyI => Code::KeyI,
|
||||||
|
W::KeyJ => Code::KeyJ,
|
||||||
|
W::KeyK => Code::KeyK,
|
||||||
|
W::KeyL => Code::KeyL,
|
||||||
|
W::KeyM => Code::KeyM,
|
||||||
|
W::KeyN => Code::KeyN,
|
||||||
|
W::KeyO => Code::KeyO,
|
||||||
|
W::KeyP => Code::KeyP,
|
||||||
|
W::KeyQ => Code::KeyQ,
|
||||||
|
W::KeyR => Code::KeyR,
|
||||||
|
W::KeyS => Code::KeyS,
|
||||||
|
W::KeyT => Code::KeyT,
|
||||||
|
W::KeyU => Code::KeyU,
|
||||||
|
W::KeyV => Code::KeyV,
|
||||||
|
W::KeyW => Code::KeyW,
|
||||||
|
W::KeyX => Code::KeyX,
|
||||||
|
W::KeyY => Code::KeyY,
|
||||||
|
W::KeyZ => Code::KeyZ,
|
||||||
|
W::Minus => Code::Minus,
|
||||||
|
W::Period => Code::Period,
|
||||||
|
W::Quote => Code::Quote,
|
||||||
|
W::Semicolon => Code::Semicolon,
|
||||||
|
W::Slash => Code::Slash,
|
||||||
|
W::Backspace => Code::Backspace,
|
||||||
|
W::CapsLock => Code::CapsLock,
|
||||||
|
W::Enter => Code::Enter,
|
||||||
|
W::Space => Code::Space,
|
||||||
|
W::Tab => Code::Tab,
|
||||||
|
W::Delete => Code::Delete,
|
||||||
|
W::End => Code::End,
|
||||||
|
W::Home => Code::Home,
|
||||||
|
W::Insert => Code::Insert,
|
||||||
|
W::PageDown => Code::PageDown,
|
||||||
|
W::PageUp => Code::PageUp,
|
||||||
|
W::ArrowDown => Code::ArrowDown,
|
||||||
|
W::ArrowLeft => Code::ArrowLeft,
|
||||||
|
W::ArrowRight => Code::ArrowRight,
|
||||||
|
W::ArrowUp => Code::ArrowUp,
|
||||||
|
W::NumLock => Code::NumLock,
|
||||||
|
W::Escape => Code::Escape,
|
||||||
|
W::F1 => Code::F1,
|
||||||
|
W::F2 => Code::F2,
|
||||||
|
W::F3 => Code::F3,
|
||||||
|
W::F4 => Code::F4,
|
||||||
|
W::F5 => Code::F5,
|
||||||
|
W::F6 => Code::F6,
|
||||||
|
W::F7 => Code::F7,
|
||||||
|
W::F8 => Code::F8,
|
||||||
|
W::F9 => Code::F9,
|
||||||
|
W::F10 => Code::F10,
|
||||||
|
W::F11 => Code::F11,
|
||||||
|
W::F12 => Code::F12,
|
||||||
|
W::ShiftLeft => Code::ShiftLeft,
|
||||||
|
W::ShiftRight => Code::ShiftRight,
|
||||||
|
W::ControlLeft => Code::ControlLeft,
|
||||||
|
W::ControlRight => Code::ControlRight,
|
||||||
|
W::AltLeft => Code::AltLeft,
|
||||||
|
W::AltRight => Code::AltRight,
|
||||||
|
W::SuperLeft => Code::MetaLeft,
|
||||||
|
W::SuperRight => Code::MetaRight,
|
||||||
|
_ => Code::Unidentified,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,8 @@ use anyhow::Context;
|
||||||
|
|
||||||
#[cfg(feature = "servo-embed")]
|
#[cfg(feature = "servo-embed")]
|
||||||
mod embedder;
|
mod embedder;
|
||||||
|
#[cfg(feature = "servo-embed")]
|
||||||
|
mod keyutils;
|
||||||
mod protocols;
|
mod protocols;
|
||||||
mod shell_client;
|
mod shell_client;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue