mirror of
https://github.com/SoftFever/OrcaSlicer.git
synced 2025-10-23 16:51:21 -06:00
Octoprint (#796)
* Octoprint: GUI for CA file, improvements * Octoprint: Add GUI for Bonjour lookup, bugfixes * Octoprint: Bonjour browser: Cleanup Perl interaction * Octoprint: Bonjour: Perform several broadcast, UI fixes * Octoprint: Add files to localization list * Http: Disable CA File setting on SSL backends that don't support it
This commit is contained in:
parent
8d4b603572
commit
c88d2780ce
21 changed files with 1587 additions and 1341 deletions
|
@ -1,9 +1,7 @@
|
|||
#include "Bonjour.hpp"
|
||||
|
||||
#include <iostream> // XXX
|
||||
#include <cstdint>
|
||||
#include <algorithm>
|
||||
#include <unordered_map>
|
||||
#include <array>
|
||||
#include <vector>
|
||||
#include <string>
|
||||
|
@ -23,16 +21,18 @@ namespace asio = boost::asio;
|
|||
using boost::asio::ip::udp;
|
||||
|
||||
|
||||
// TODO: Fuzzing test (done without TXT)
|
||||
// FIXME: check char retype to unsigned
|
||||
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
|
||||
// Minimal implementation of a MDNS/DNS-SD client
|
||||
// This implementation is extremely simple, only the bits that are useful
|
||||
// for very basic MDNS discovery are present.
|
||||
// for basic MDNS discovery of OctoPi devices are present.
|
||||
// However, the bits that are present are implemented with security in mind.
|
||||
// Only fully correct DNS replies are allowed through.
|
||||
// While decoding the decoder will bail the moment it encounters anything fishy.
|
||||
// At least that's the idea. To help prove this is actually the case,
|
||||
// the implementations has been tested with AFL.
|
||||
|
||||
|
||||
struct DnsName: public std::string
|
||||
{
|
||||
|
@ -48,8 +48,7 @@ struct DnsName: public std::string
|
|||
return boost::none;
|
||||
}
|
||||
|
||||
// Check for recursion depth to prevent parsing names that are nested too deeply
|
||||
// or end up cyclic:
|
||||
// Check for recursion depth to prevent parsing names that are nested too deeply or end up cyclic:
|
||||
if (depth >= MAX_RECURSION) {
|
||||
return boost::none;
|
||||
}
|
||||
|
@ -443,6 +442,30 @@ private:
|
|||
}
|
||||
};
|
||||
|
||||
std::ostream& operator<<(std::ostream &os, const DnsMessage &msg)
|
||||
{
|
||||
os << "DnsMessage(ID: " << msg.header.id << ", "
|
||||
<< "Q: " << (msg.question ? msg.question->name.c_str() : "none") << ", "
|
||||
<< "A: " << (msg.rr_a ? msg.rr_a->ip.to_string() : "none") << ", "
|
||||
<< "AAAA: " << (msg.rr_aaaa ? msg.rr_aaaa->ip.to_string() : "none") << ", "
|
||||
<< "services: [";
|
||||
|
||||
enum { SRV_PRINT_MAX = 3 };
|
||||
unsigned i = 0;
|
||||
for (const auto &sdpair : msg.sdmap) {
|
||||
os << sdpair.first << ", ";
|
||||
|
||||
if (++i >= SRV_PRINT_MAX) {
|
||||
os << "...";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
os << "])";
|
||||
|
||||
return os;
|
||||
}
|
||||
|
||||
|
||||
struct BonjourRequest
|
||||
{
|
||||
|
@ -515,6 +538,7 @@ struct Bonjour::priv
|
|||
const std::string protocol;
|
||||
const std::string service_dn;
|
||||
unsigned timeout;
|
||||
unsigned retries;
|
||||
uint16_t rq_id;
|
||||
|
||||
std::vector<char> buffer;
|
||||
|
@ -524,6 +548,7 @@ struct Bonjour::priv
|
|||
|
||||
priv(std::string service, std::string protocol);
|
||||
|
||||
std::string strip_service_dn(const std::string &service_name) const;
|
||||
void udp_receive(udp::endpoint from, size_t bytes);
|
||||
void lookup_perform();
|
||||
};
|
||||
|
@ -533,11 +558,26 @@ Bonjour::priv::priv(std::string service, std::string protocol) :
|
|||
protocol(std::move(protocol)),
|
||||
service_dn((boost::format("_%1%._%2%.local") % this->service % this->protocol).str()),
|
||||
timeout(10),
|
||||
retries(1),
|
||||
rq_id(0)
|
||||
{
|
||||
buffer.resize(DnsMessage::MAX_SIZE);
|
||||
}
|
||||
|
||||
std::string Bonjour::priv::strip_service_dn(const std::string &service_name) const
|
||||
{
|
||||
if (service_name.size() <= service_dn.size()) {
|
||||
return service_name;
|
||||
}
|
||||
|
||||
auto needle = service_name.rfind(service_dn);
|
||||
if (needle == service_name.size() - service_dn.size()) {
|
||||
return service_name.substr(0, needle - 1);
|
||||
} else {
|
||||
return service_name;
|
||||
}
|
||||
}
|
||||
|
||||
void Bonjour::priv::udp_receive(udp::endpoint from, size_t bytes)
|
||||
{
|
||||
if (bytes == 0 || !replyfn) {
|
||||
|
@ -557,7 +597,10 @@ void Bonjour::priv::udp_receive(udp::endpoint from, size_t bytes)
|
|||
}
|
||||
|
||||
const auto &srv = *sdpair.second.srv;
|
||||
BonjourReply reply(ip, sdpair.first, srv.hostname);
|
||||
auto service_name = strip_service_dn(sdpair.first);
|
||||
|
||||
std::string path;
|
||||
std::string version;
|
||||
|
||||
if (sdpair.second.txt) {
|
||||
static const std::string tag_path = "path=";
|
||||
|
@ -565,13 +608,14 @@ void Bonjour::priv::udp_receive(udp::endpoint from, size_t bytes)
|
|||
|
||||
for (const auto &value : sdpair.second.txt->values) {
|
||||
if (value.size() > tag_path.size() && value.compare(0, tag_path.size(), tag_path) == 0) {
|
||||
reply.path = value.substr(tag_path.size());
|
||||
path = std::move(value.substr(tag_path.size()));
|
||||
} else if (value.size() > tag_version.size() && value.compare(0, tag_version.size(), tag_version) == 0) {
|
||||
reply.version = value.substr(tag_version.size());
|
||||
version = std::move(value.substr(tag_version.size()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BonjourReply reply(ip, srv.port, std::move(service_name), srv.hostname, std::move(path), std::move(version));
|
||||
replyfn(std::move(reply));
|
||||
}
|
||||
}
|
||||
|
@ -595,15 +639,26 @@ void Bonjour::priv::lookup_perform()
|
|||
udp::endpoint mcast(BonjourRequest::MCAST_IP4, BonjourRequest::MCAST_PORT);
|
||||
socket.send_to(asio::buffer(brq->data), mcast);
|
||||
|
||||
bool timeout = false;
|
||||
bool expired = false;
|
||||
bool retry = false;
|
||||
asio::deadline_timer timer(io_service);
|
||||
timer.expires_from_now(boost::posix_time::seconds(10));
|
||||
timer.async_wait([=, &timeout](const error_code &error) {
|
||||
timeout = true;
|
||||
if (self->completefn) {
|
||||
self->completefn();
|
||||
retries--;
|
||||
std::function<void(const error_code &)> timer_handler = [&](const error_code &error) {
|
||||
if (retries == 0 || error) {
|
||||
expired = true;
|
||||
if (self->completefn) {
|
||||
self->completefn();
|
||||
}
|
||||
} else {
|
||||
retry = true;
|
||||
retries--;
|
||||
timer.expires_from_now(boost::posix_time::seconds(timeout));
|
||||
timer.async_wait(timer_handler);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
timer.expires_from_now(boost::posix_time::seconds(timeout));
|
||||
timer.async_wait(timer_handler);
|
||||
|
||||
udp::endpoint recv_from;
|
||||
const auto recv_handler = [&](const error_code &error, size_t bytes) {
|
||||
|
@ -612,8 +667,11 @@ void Bonjour::priv::lookup_perform()
|
|||
socket.async_receive_from(asio::buffer(buffer, buffer.size()), recv_from, recv_handler);
|
||||
|
||||
while (io_service.run_one()) {
|
||||
if (timeout) {
|
||||
if (expired) {
|
||||
socket.cancel();
|
||||
} else if (retry) {
|
||||
retry = false;
|
||||
socket.send_to(asio::buffer(brq->data), mcast);
|
||||
} else {
|
||||
buffer.resize(DnsMessage::MAX_SIZE);
|
||||
socket.async_receive_from(asio::buffer(buffer, buffer.size()), recv_from, recv_handler);
|
||||
|
@ -626,13 +684,39 @@ void Bonjour::priv::lookup_perform()
|
|||
|
||||
// API - public part
|
||||
|
||||
BonjourReply::BonjourReply(boost::asio::ip::address ip, std::string service_name, std::string hostname) :
|
||||
BonjourReply::BonjourReply(boost::asio::ip::address ip, uint16_t port, std::string service_name, std::string hostname, std::string path, std::string version) :
|
||||
ip(std::move(ip)),
|
||||
port(port),
|
||||
service_name(std::move(service_name)),
|
||||
hostname(std::move(hostname)),
|
||||
path("/"),
|
||||
version("Unknown")
|
||||
{}
|
||||
path(path.empty() ? std::move(std::string("/")) : std::move(path)),
|
||||
version(version.empty() ? std::move(std::string("Unknown")) : std::move(version))
|
||||
{
|
||||
std::string proto;
|
||||
std::string port_suffix;
|
||||
if (port == 443) { proto = "https://"; }
|
||||
if (port != 443 && port != 80) { port_suffix = std::to_string(port).insert(0, 1, ':'); }
|
||||
if (this->path[0] != '/') { this->path.insert(0, 1, '/'); }
|
||||
full_address = proto + ip.to_string() + port_suffix;
|
||||
if (this->path != "/") { full_address += path; }
|
||||
}
|
||||
|
||||
bool BonjourReply::operator==(const BonjourReply &other) const
|
||||
{
|
||||
return this->full_address == other.full_address
|
||||
&& this->service_name == other.service_name;
|
||||
}
|
||||
|
||||
bool BonjourReply::operator<(const BonjourReply &other) const
|
||||
{
|
||||
if (this->ip != other.ip) {
|
||||
// So that the common case doesn't involve string comparison
|
||||
return this->ip < other.ip;
|
||||
} else {
|
||||
auto cmp = this->full_address.compare(other.full_address);
|
||||
return cmp != 0 ? cmp < 0 : this->service_name < other.service_name;
|
||||
}
|
||||
}
|
||||
|
||||
std::ostream& operator<<(std::ostream &os, const BonjourReply &reply)
|
||||
{
|
||||
|
@ -641,6 +725,7 @@ std::ostream& operator<<(std::ostream &os, const BonjourReply &reply)
|
|||
return os;
|
||||
}
|
||||
|
||||
|
||||
Bonjour::Bonjour(std::string service, std::string protocol) :
|
||||
p(new priv(std::move(service), std::move(protocol)))
|
||||
{}
|
||||
|
@ -660,6 +745,12 @@ Bonjour& Bonjour::set_timeout(unsigned timeout)
|
|||
return *this;
|
||||
}
|
||||
|
||||
Bonjour& Bonjour::set_retries(unsigned retries)
|
||||
{
|
||||
if (p && retries > 0) { p->retries = retries; }
|
||||
return *this;
|
||||
}
|
||||
|
||||
Bonjour& Bonjour::on_reply(ReplyFn fn)
|
||||
{
|
||||
if (p) { p->replyfn = std::move(fn); }
|
||||
|
@ -677,7 +768,7 @@ Bonjour::Ptr Bonjour::lookup()
|
|||
auto self = std::make_shared<Bonjour>(std::move(*this));
|
||||
|
||||
if (self->p) {
|
||||
auto io_thread = std::thread([self](){
|
||||
auto io_thread = std::thread([self]() {
|
||||
self->p->lookup_perform();
|
||||
});
|
||||
self->p->io_thread = std::move(io_thread);
|
||||
|
@ -687,18 +778,4 @@ Bonjour::Ptr Bonjour::lookup()
|
|||
}
|
||||
|
||||
|
||||
void Bonjour::pokus() // XXX
|
||||
{
|
||||
auto bonjour = Bonjour("octoprint")
|
||||
.set_timeout(15)
|
||||
.on_reply([](BonjourReply &&reply) {
|
||||
std::cerr << "BonjourReply: " << reply << std::endl;
|
||||
})
|
||||
.on_complete([](){
|
||||
std::cerr << "MDNS lookup complete" << std::endl;
|
||||
})
|
||||
.lookup();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
@ -1,26 +1,31 @@
|
|||
#ifndef slic3r_Bonjour_hpp_
|
||||
#define slic3r_Bonjour_hpp_
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <functional>
|
||||
// #include <ostream>
|
||||
#include <boost/asio/ip/address.hpp>
|
||||
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
|
||||
// TODO: reply data structure
|
||||
struct BonjourReply
|
||||
{
|
||||
boost::asio::ip::address ip;
|
||||
uint16_t port;
|
||||
std::string service_name;
|
||||
std::string hostname;
|
||||
std::string full_address;
|
||||
std::string path;
|
||||
std::string version;
|
||||
|
||||
BonjourReply(boost::asio::ip::address ip, std::string service_name, std::string hostname);
|
||||
BonjourReply() = delete;
|
||||
BonjourReply(boost::asio::ip::address ip, uint16_t port, std::string service_name, std::string hostname, std::string path, std::string version);
|
||||
|
||||
bool operator==(const BonjourReply &other) const;
|
||||
bool operator<(const BonjourReply &other) const;
|
||||
};
|
||||
|
||||
std::ostream& operator<<(std::ostream &, const BonjourReply &);
|
||||
|
@ -32,7 +37,7 @@ private:
|
|||
struct priv;
|
||||
public:
|
||||
typedef std::shared_ptr<Bonjour> Ptr;
|
||||
typedef std::function<void(BonjourReply &&reply)> ReplyFn;
|
||||
typedef std::function<void(BonjourReply &&)> ReplyFn;
|
||||
typedef std::function<void()> CompleteFn;
|
||||
|
||||
Bonjour(std::string service, std::string protocol = "tcp");
|
||||
|
@ -40,12 +45,15 @@ public:
|
|||
~Bonjour();
|
||||
|
||||
Bonjour& set_timeout(unsigned timeout);
|
||||
Bonjour& set_retries(unsigned retries);
|
||||
// ^ Note: By default there is 1 retry (meaning 1 broadcast is sent).
|
||||
// Timeout is per one retry, ie. total time spent listening = retries * timeout.
|
||||
// If retries > 1, then care needs to be taken as more than one reply from the same service may be received.
|
||||
|
||||
Bonjour& on_reply(ReplyFn fn);
|
||||
Bonjour& on_complete(CompleteFn fn);
|
||||
|
||||
Ptr lookup();
|
||||
|
||||
static void pokus(); // XXX: remove
|
||||
private:
|
||||
std::unique_ptr<priv> p;
|
||||
};
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
#include <cstdlib>
|
||||
#include <functional>
|
||||
#include <thread>
|
||||
#include <iostream>
|
||||
#include <tuple>
|
||||
#include <boost/format.hpp>
|
||||
|
||||
|
@ -45,7 +44,9 @@ struct Http::priv
|
|||
priv(const std::string &url);
|
||||
~priv();
|
||||
|
||||
static bool ca_file_supported(::CURL *curl);
|
||||
static size_t writecb(void *data, size_t size, size_t nmemb, void *userp);
|
||||
std::string curl_error(CURLcode curlcode);
|
||||
std::string body_size_error();
|
||||
void http_perform();
|
||||
};
|
||||
|
@ -71,6 +72,29 @@ Http::priv::~priv()
|
|||
::curl_slist_free_all(headerlist);
|
||||
}
|
||||
|
||||
bool Http::priv::ca_file_supported(::CURL *curl)
|
||||
{
|
||||
#ifdef _WIN32
|
||||
bool res = false;
|
||||
#else
|
||||
bool res = true;
|
||||
#endif
|
||||
|
||||
if (curl == nullptr) { return res; }
|
||||
|
||||
#if LIBCURL_VERSION_MAJOR >= 7 && LIBCURL_VERSION_MINOR >= 48
|
||||
::curl_tlssessioninfo *tls;
|
||||
if (::curl_easy_getinfo(curl, CURLINFO_TLS_SSL_PTR, &tls) == CURLE_OK) {
|
||||
if (tls->backend == CURLSSLBACKEND_SCHANNEL || tls->backend == CURLSSLBACKEND_DARWINSSL) {
|
||||
// With Windows and OS X native SSL support, cert files cannot be set
|
||||
res = false;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
size_t Http::priv::writecb(void *data, size_t size, size_t nmemb, void *userp)
|
||||
{
|
||||
auto self = static_cast<priv*>(userp);
|
||||
|
@ -88,6 +112,14 @@ size_t Http::priv::writecb(void *data, size_t size, size_t nmemb, void *userp)
|
|||
return realsize;
|
||||
}
|
||||
|
||||
std::string Http::priv::curl_error(CURLcode curlcode)
|
||||
{
|
||||
return (boost::format("%1% (%2%)")
|
||||
% ::curl_easy_strerror(curlcode)
|
||||
% curlcode
|
||||
).str();
|
||||
}
|
||||
|
||||
std::string Http::priv::body_size_error()
|
||||
{
|
||||
return (boost::format("HTTP body data size exceeded limit (%1% bytes)") % limit).str();
|
||||
|
@ -121,7 +153,7 @@ void Http::priv::http_perform()
|
|||
if (res == CURLE_WRITE_ERROR) {
|
||||
error = std::move(body_size_error());
|
||||
} else {
|
||||
error = ::curl_easy_strerror(res);
|
||||
error = std::move(curl_error(res));
|
||||
};
|
||||
|
||||
if (errorfn) {
|
||||
|
@ -180,7 +212,7 @@ Http& Http::remove_header(std::string name)
|
|||
|
||||
Http& Http::ca_file(const std::string &name)
|
||||
{
|
||||
if (p) {
|
||||
if (p && priv::ca_file_supported(p->curl)) {
|
||||
::curl_easy_setopt(p->curl, CURLOPT_CAINFO, name.c_str());
|
||||
}
|
||||
|
||||
|
@ -257,5 +289,13 @@ Http Http::post(std::string url)
|
|||
return http;
|
||||
}
|
||||
|
||||
bool Http::ca_file_supported()
|
||||
{
|
||||
::CURL *curl = ::curl_easy_init();
|
||||
bool res = priv::ca_file_supported(curl);
|
||||
if (curl != nullptr) { ::curl_easy_cleanup(curl); }
|
||||
return res;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
@ -41,6 +41,7 @@ public:
|
|||
Ptr perform();
|
||||
void perform_sync();
|
||||
|
||||
static bool ca_file_supported();
|
||||
private:
|
||||
Http(const std::string &url);
|
||||
|
||||
|
|
|
@ -20,16 +20,19 @@ OctoPrint::OctoPrint(DynamicPrintConfig *config) :
|
|||
cafile(config->opt_string("octoprint_cafile"))
|
||||
{}
|
||||
|
||||
std::string OctoPrint::test() const
|
||||
bool OctoPrint::test(wxString &msg) const
|
||||
{
|
||||
// Since the request is performed synchronously here,
|
||||
// it is ok to refer to `res` from within the closure
|
||||
std::string res;
|
||||
// it is ok to refer to `msg` from within the closure
|
||||
|
||||
auto http = Http::get(std::move(make_url("api/version")));
|
||||
bool res = true;
|
||||
|
||||
auto url = std::move(make_url("api/version"));
|
||||
auto http = Http::get(std::move(url));
|
||||
set_auth(http);
|
||||
http.on_error([&](std::string, std::string error, unsigned status) {
|
||||
res = format_error(error, status);
|
||||
res = false;
|
||||
msg = format_error(error, status);
|
||||
})
|
||||
.perform_sync();
|
||||
|
||||
|
@ -43,21 +46,26 @@ void OctoPrint::send_gcode(int windowId, int completeEvt, int errorEvt, const st
|
|||
http.form_add("print", print ? "true" : "false")
|
||||
.form_add_file("file", filename)
|
||||
.on_complete([=](std::string body, unsigned status) {
|
||||
wxWindow *window = GUI::get_widget_by_id(windowId);
|
||||
wxWindow *window = wxWindow::FindWindowById(windowId);
|
||||
if (window == nullptr) { return; }
|
||||
|
||||
wxCommandEvent* evt = new wxCommandEvent(completeEvt);
|
||||
evt->SetString("G-code file successfully uploaded to the OctoPrint server");
|
||||
evt->SetString(_(L("G-code file successfully uploaded to the OctoPrint server")));
|
||||
evt->SetInt(100);
|
||||
wxQueueEvent(window, evt);
|
||||
})
|
||||
.on_error([=](std::string body, std::string error, unsigned status) {
|
||||
wxWindow *window = GUI::get_widget_by_id(windowId);
|
||||
wxWindow *window = wxWindow::FindWindowById(windowId);
|
||||
if (window == nullptr) { return; }
|
||||
|
||||
wxCommandEvent* evt_complete = new wxCommandEvent(completeEvt);
|
||||
evt_complete->SetInt(100);
|
||||
wxQueueEvent(window, evt_complete);
|
||||
|
||||
wxCommandEvent* evt_error = new wxCommandEvent(errorEvt);
|
||||
evt_error->SetString(wxString::Format("Error while uploading to the OctoPrint server: %s", format_error(error, status)));
|
||||
evt_error->SetString(wxString::Format("%s: %s",
|
||||
_(L("Error while uploading to the OctoPrint server")),
|
||||
format_error(error, status)));
|
||||
wxQueueEvent(window, evt_error);
|
||||
})
|
||||
.perform();
|
||||
|
@ -85,19 +93,15 @@ std::string OctoPrint::make_url(const std::string &path) const
|
|||
}
|
||||
}
|
||||
|
||||
std::string OctoPrint::format_error(std::string error, unsigned status)
|
||||
wxString OctoPrint::format_error(std::string error, unsigned status)
|
||||
{
|
||||
const wxString wxerror = error;
|
||||
|
||||
if (status != 0) {
|
||||
std::string res{"HTTP "};
|
||||
res.append(std::to_string(status));
|
||||
|
||||
if (status == 401) {
|
||||
res.append(": Invalid API key");
|
||||
}
|
||||
|
||||
return std::move(res);
|
||||
return wxString::Format("HTTP %u: %s", status,
|
||||
(status == 401 ? _(L("Invalid API key")) : wxerror));
|
||||
} else {
|
||||
return std::move(error);
|
||||
return std::move(wxerror);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2,8 +2,8 @@
|
|||
#define slic3r_OctoPrint_hpp_
|
||||
|
||||
#include <string>
|
||||
#include <wx/string.h>
|
||||
|
||||
// #include "Http.hpp" // XXX: ?
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
|
@ -16,8 +16,7 @@ class OctoPrint
|
|||
public:
|
||||
OctoPrint(DynamicPrintConfig *config);
|
||||
|
||||
std::string test() const;
|
||||
// XXX: style
|
||||
bool test(wxString &curl_msg) const;
|
||||
void send_gcode(int windowId, int completeEvt, int errorEvt, const std::string &filename, bool print = false) const;
|
||||
private:
|
||||
std::string host;
|
||||
|
@ -26,7 +25,7 @@ private:
|
|||
|
||||
void set_auth(Http &http) const;
|
||||
std::string make_url(const std::string &path) const;
|
||||
static std::string format_error(std::string error, unsigned status);
|
||||
static wxString format_error(std::string error, unsigned status);
|
||||
};
|
||||
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue