mirror of
https://github.com/SpartanJ/eepp.git
synced 2026-10-18 11:33:23 +03:00
Add tabbed HTML browsing and extend eepp navigation and input support
eepp framework: - Add shared cookie jars to UISceneNode and UIWebView, plus versioned CookieManager persistence, domain clearing, and locking in hasCookie(). - Follow HTTP 303 redirects and merge same-host redirect cookies as name/value pairs without forwarding Set-Cookie attributes. - Include Cookie headers in document cache keys while preserving static resource reuse as cookies change. - Submit HTML forms with an empty action to the current document URL. - Extend NavigationRequest with source, mouse buttons, modifiers, and navigation target; anchors forward this context to the scene. - Emit OnLinkOpenRequested for middle-click, platform-modifier click, and explicit new-tab requests, with current-view fallback if unhandled. - Install the WebView navigation interceptor during construction. - Dispatch middle clicks to the hovered node without moving keyboard focus. - Add the clicked target to ContextMenuEvent so applications can extend context menus through existing events. - Parse HTML titles, emit TitleEvent, and fix onTitleChanged's payload cast. - Load declared favicons through the shared image cache, emit OnFaviconChanged, and ignore responses from superseded navigation. - Add optional UITabWidgetSplitter tab-bar visibility while split; keep it disabled by default and respect explicitly hidden tab bars. - Add Space/Shift+Space scrolling and viewport-sized Page Up/Down steps to UIScrollView, shared by WebView and Markdown content. - Implement setEnableDefaultKeybindings() and preserve text-input priority through normal bubbling without control-type checks. - Report inserted UICodeEditor text as handled. - Add UITextView::onFontColorChanged() and synchronize password-mask color. - Support validated left, middle, and right clicks in the inspector server and Python client. ui_html example: - Use UITabWidgetSplitter for tabs and user-controlled splits. - Share cookies and the resource cache across tabs; persist cookies between sessions and open new tabs blank. - Add Inspect, Clear Domain Cookies, Clear All Cookies, and contextual Open Link in New Tab actions through menu event subscriptions. - Open requested links in background tabs and synchronize navigation UI. - Use document titles and 16dp favicons for tabs, cap width at 200dp, and truncate long titles with ellipsis. - Enable tab-bar visibility across split panes. - Fixed: the selection rectangle now uses the visible password symbols instead of the original text. Update HTML compatibility and inspector documentation and add regression coverage for persistence, redirects, cache isolation, navigation, menus, titles, favicons, password colors, split visibility, and scrolling.
This commit is contained in:
@@ -1005,8 +1005,36 @@ back into the owning `UIWebView`, including relative URL resolution.
|
||||
|
||||
This means ordinary links can navigate the current web view.
|
||||
|
||||
`NavigationRequest` carries the source node, mouse buttons, and keyboard modifiers. Middle click,
|
||||
the platform modifier plus left click, or an explicit `Target::NewTab` request emits
|
||||
`Event::OnLinkOpenRequested` on the WebView. Subscribe to this event, open the resolved URI in a
|
||||
new view, and call `UIWebView::LinkOpenEvent::accept()` to handle it. Unhandled requests navigate
|
||||
the current view. Applications add their own link menu actions through `OnCreateContextMenu`;
|
||||
`ContextMenuEvent::getTarget()` identifies the clicked document node.
|
||||
|
||||
`getTitle()` returns the parsed HTML document title. `onTitleChanged()` reports the title after
|
||||
loading, including an empty title for pages without one. The `ui_html` example uses it for tab labels
|
||||
and falls back to the host or URL. Tabs cap their width at 200dp and truncate long titles with ellipsis.
|
||||
The example enables `UITabWidgetSplitter::setShowTabBarWhenSplit(true)` to keep each pane's tab bar
|
||||
visible while split. This option is disabled by default for other splitter users.
|
||||
|
||||
`<link rel="icon" href="...">` (including `rel="shortcut icon"`) loads the declared favicon through
|
||||
the document's shared image cache. Relative URLs resolve against the document URL, and SVG icons
|
||||
are supported. `Event::OnFaviconChanged` carries a `UIWebView::FaviconEvent` with the loaded texture;
|
||||
an empty icon clears the previous document's favicon. Responses from an older navigation are ignored.
|
||||
The `ui_html` example displays the texture as a 16dp tab icon.
|
||||
|
||||
`UIWebView` maintains its own navigation history.
|
||||
|
||||
`UIMarkdownView` uses the same shortcuts through its enclosing `UIScrollView`; disable them on
|
||||
that scroll view with `setEnableDefaultKeybindings(false)`.
|
||||
|
||||
Default scrolling shortcuts are Space and Page Down (one viewport down), Shift+Space and Page Up
|
||||
(one viewport up). Keys handled by a focused descendant do not reach the WebView. While text input is active,
|
||||
Space scrolls only if its committed text reaches the WebView unhandled; custom controls can consume
|
||||
it through `onTextInput()` like built-in editors. Call `setEnableDefaultKeybindings(false)` to disable scrolling
|
||||
shortcuts, including the inherited arrow and Home/End bindings.
|
||||
|
||||
Anchor interaction also cooperates with document text selection so a completed text drag does not
|
||||
accidentally activate the link.
|
||||
|
||||
@@ -1074,6 +1102,8 @@ other text-like types -> UIHTMLTextInput
|
||||
|
||||
Forms collect named control values and can submit navigation requests.
|
||||
|
||||
An omitted or empty form `action` submits to the full current document URL, preserving its query.
|
||||
|
||||
Supported submission encodings include:
|
||||
|
||||
```text
|
||||
|
||||
@@ -54,6 +54,7 @@ python3 projects/scripts/eepp-inspect.py focus --scene scene:2
|
||||
python3 projects/scripts/eepp-inspect.py screenshot --scene scene:2 --format png
|
||||
python3 projects/scripts/eepp-inspect.py screenshot --window win:2 --rect 10 20 400 300 --format webp
|
||||
python3 projects/scripts/eepp-inspect.py click --scene scene:2 '#submit'
|
||||
python3 projects/scripts/eepp-inspect.py click --button right --scene scene:2 '#content'
|
||||
python3 projects/scripts/eepp-inspect.py key --scene scene:2 Enter
|
||||
python3 projects/scripts/eepp-inspect.py type --scene scene:2 'hello world'
|
||||
python3 projects/scripts/eepp-inspect.py type --target '#search' 'hello world'
|
||||
@@ -114,7 +115,7 @@ All examples below omit `id` where the surrounding explanation is enough; real r
|
||||
| `ui.inspect` | Exactly one of `handle` or `selector` plus optional `scene`; optional `properties`, `maxStringLength:4096` (maximum 65536) | Handle, scene, typed `properties`, `unavailable` | `invalid-widget`, `target-not-found`, `target-ambiguous` |
|
||||
| `ui.focus` | `scene` default | Scene and focused widget summary or `null` | `invalid-scene` |
|
||||
| `ui.screenshot` | Optional `scene` or `window` (mutually exclusive); optional `rect:[x,y,width,height]`; `format:"png"` | Temporary file path, format, window/scene handles, captured rectangle and size | `invalid-window`, `invalid-scene`, `invalid-params`, `screenshot-failed` |
|
||||
| `input.click` | Exactly one target; optional `button:"left"`, `count:1` (maximum 2) | Target handle, scene, click point | `target-not-interactable`, `permission-denied` |
|
||||
| `input.click` | Exactly one target; optional `button:"left"`, `"middle"`, or `"right"`, `count:1` (maximum 2) | Target handle, scene, click point | `target-not-interactable`, `permission-denied` |
|
||||
| `input.key` | `scene` default, `key` required, `action:"press"`, `modifiers:[]` | Key and action | `invalid-params`, `permission-denied` |
|
||||
| `input.text` | `scene` default, `text` required (maximum 64 KiB UTF-8) | Character count | `invalid-params`, `permission-denied` |
|
||||
| `watch.subscribe` | One handle or selector; explicit `properties` array; `initial:true` | Subscription and bound target values | `target-not-found`, `property-unknown` |
|
||||
@@ -159,7 +160,20 @@ These compact request/response pairs show each method's envelope. Handle numbers
|
||||
|
||||
`ui.screenshot` captures the native window containing the selected scene. With no target it captures the default scene's full native window. An explicit `window` captures that whole window; an explicit `scene` crops to its visible world bounds, including a WebView document viewport. `rect` overrides that crop and uses top-left native-window pixel coordinates. It must fit entirely inside the window and have positive width and height. Supported formats are `png`, `jpg`, `bmp`, `tga`, `qoi`, and `webp`; eepp's case-insensitive image extension parser also accepts `jpeg` and `jfif` as aliases for `jpg`. The response uses the canonical extension. The server renders the selected window after the current UI update, saves the image in the OS temporary directory, and returns its absolute `path`; it does not send image bytes over NDJSON. The caller is responsible for deleting the file when finished. The path is useful to a local agent; remote clients must separately retrieve the file, for example over SSH. Screenshot observation is available in read-only mode and can be used inside `session.batch`.
|
||||
|
||||
Single-widget methods never pick the first of multiple selector matches: zero matches return `target-not-found`, and multiple matches return `target-ambiguous`. `input.click` uses the window input route and scene hit testing; clipped, covered, invisible, disabled, or off-window targets can return `target-not-interactable`. It does not scroll a target into view. `input.key` accepts names understood by eepp's `Input::getKeyFromName()` plus `Enter` as an alias for `Return`, and modifiers `Ctrl`, `Shift`, `Alt`, `Meta`; action may be `press`, `down`, or `up`. `input.text` sends Unicode text input to the current focus path and does not focus a widget first. Read-only mode omits input capabilities and returns `permission-denied` for these methods.
|
||||
Single-widget methods never pick the first of multiple selector matches: zero matches return `target-not-found`, and multiple matches return `target-ambiguous`. `input.click` uses the window input route and scene hit testing; clipped, covered, invisible, disabled, or off-window targets can return `target-not-interactable`. It does not scroll a target into view. The default button is `left`; set `button` to `middle` for a middle click or `right` to open a context menu. Other button values return `invalid-params`. `input.key` accepts names understood by eepp's `Input::getKeyFromName()` plus `Enter` as an alias for `Return`, and modifiers `Ctrl`, `Shift`, `Alt`, `Meta`; action may be `press`, `down`, or `up`. `input.text` sends Unicode text input to the current focus path and does not focus a widget first. Read-only mode omits input capabilities and returns `permission-denied` for these methods.
|
||||
|
||||
### Checking context menus
|
||||
|
||||
Right-click a visible target, then query the popup and its items. For a WebView, the HTML target belongs to its document scene, while the popup usually belongs to the parent application scene:
|
||||
|
||||
```sh
|
||||
python3 projects/scripts/eepp-inspect.py contexts
|
||||
python3 projects/scripts/eepp-inspect.py click --button right --scene scene:2 '#context-target'
|
||||
python3 projects/scripts/eepp-inspect.py query --scene scene:1 'popupmenu'
|
||||
python3 projects/scripts/eepp-inspect.py tree --scene scene:1 --depth 5
|
||||
```
|
||||
|
||||
`input.click` aims at the target's center. Choose a visible child if a large HTML element extends beyond the window; a body whose center is off-screen will return `target-not-interactable`. The command reports the chosen point in window pixels. The same button option works in a raw or batched `input.click` request: `{"method":"input.click","params":{"scene":"scene:2","selector":"#context-target","button":"right"}}`.
|
||||
|
||||
## Properties
|
||||
|
||||
|
||||
@@ -25,6 +25,14 @@ class EE_API CookieManager {
|
||||
/** Remove all stored cookies. */
|
||||
void clear();
|
||||
|
||||
/** Remove cookies stored for exactly this domain. */
|
||||
void clearDomain( const std::string& domain );
|
||||
|
||||
/** Load or save this jar to a versioned file. Loading replaces the current contents. */
|
||||
bool loadFromFile( const std::string& path );
|
||||
|
||||
bool saveToFile( const std::string& path ) const;
|
||||
|
||||
/** @return The number of cookie entries across all domains. */
|
||||
size_t size() const;
|
||||
|
||||
|
||||
@@ -137,6 +137,8 @@ class EE_API Event {
|
||||
OnHideFindReplace,
|
||||
OnTooltipCreated,
|
||||
MouseWheel,
|
||||
OnLinkOpenRequested,
|
||||
OnFaviconChanged,
|
||||
NoEvent = eeINDEX_NOT_FOUND
|
||||
};
|
||||
|
||||
|
||||
@@ -120,6 +120,12 @@ class EE_API UITabWidgetSplitter {
|
||||
|
||||
void setHideTabBarOnSingleTab( bool hideTabBarOnSingleTab );
|
||||
|
||||
/** Whether splitting keeps single-tab bars visible. Disabled by default.
|
||||
* Explicitly hidden tab bars (setHideTabBar) remain hidden. */
|
||||
bool getShowTabBarWhenSplit() const;
|
||||
|
||||
void setShowTabBarWhenSplit( bool showTabBarWhenSplit );
|
||||
|
||||
void setHideTabBar( bool hideTabBar );
|
||||
|
||||
bool getVisualSplitting() const;
|
||||
@@ -255,6 +261,7 @@ class EE_API UITabWidgetSplitter {
|
||||
bool mHideTabBar{ false };
|
||||
bool mHideTabBarOnSingleTab{ true };
|
||||
bool mVisualSplitting{ true };
|
||||
bool mShowTabBarWhenSplit{ false };
|
||||
Float mVisualSplitEdgePercent{ 0.1 };
|
||||
Mutex mTabWidgetMutex;
|
||||
std::function<void( UITabWidget* )> mOnTabWidgetCreateCb;
|
||||
@@ -274,6 +281,8 @@ class EE_API UITabWidgetSplitter {
|
||||
|
||||
void detachWidgetEvents( UIWidget* widget );
|
||||
|
||||
void updateTabBarVisibility();
|
||||
|
||||
void closeAllTabs( std::vector<UITab*> tabs, UITabWidget::FocusTabBehavior focusTabBehavior );
|
||||
|
||||
void updateTabWidgetVisualSplitting();
|
||||
|
||||
@@ -48,13 +48,17 @@ class EE_API UIPopUpMenu : public UIMenu {
|
||||
class EE_API ContextMenuEvent : public MouseEvent {
|
||||
public:
|
||||
ContextMenuEvent( Node* node, UIPopUpMenu* menu, const Uint32& eventType, const Vector2i& pos,
|
||||
const Uint32& flags ) :
|
||||
MouseEvent( node, eventType, pos, flags ), menu( menu ) {}
|
||||
const Uint32& flags, const Node* target = nullptr ) :
|
||||
MouseEvent( node, eventType, pos, flags ), menu( menu ), target( target ) {}
|
||||
|
||||
UIPopUpMenu* getMenu() const { return menu; }
|
||||
|
||||
/** The widget under the pointer, when the menu creator provides one. */
|
||||
const Node* getTarget() const { return target; }
|
||||
|
||||
protected:
|
||||
UIPopUpMenu* menu;
|
||||
const Node* target;
|
||||
};
|
||||
|
||||
}} // namespace EE::UI
|
||||
|
||||
@@ -44,10 +44,16 @@ class UIIcon;
|
||||
class UIRoot;
|
||||
|
||||
struct NavigationRequest {
|
||||
enum class Target : Uint8 { Current, NewTab };
|
||||
|
||||
URI uri;
|
||||
std::string method{ "GET" };
|
||||
std::string body;
|
||||
std::map<std::string, std::string> extraHeaders;
|
||||
const Node* source{ nullptr };
|
||||
Uint32 mouseButtons{ 0 };
|
||||
Uint32 modifiers{ 0 };
|
||||
Target target{ Target::Current };
|
||||
};
|
||||
|
||||
class EE_API UISceneNode : public SceneNode {
|
||||
@@ -931,9 +937,14 @@ class EE_API UISceneNode : public SceneNode {
|
||||
/** @return The document referer */
|
||||
URI getReferer() const { return mReferer; };
|
||||
|
||||
const Network::CookieManager& getCookieManager() const { return mCookieManager; }
|
||||
const CookieManager& getCookieManager() const { return *mCookieManager; }
|
||||
|
||||
Network::CookieManager& getCookieManager() { return mCookieManager; }
|
||||
CookieManager& getCookieManager() { return *mCookieManager; }
|
||||
|
||||
/** Share one cookie jar across document scenes. Passing nullptr creates a fresh private jar. */
|
||||
void setCookieManager( std::shared_ptr<CookieManager> manager ) {
|
||||
mCookieManager = manager ? std::move( manager ) : std::make_shared<CookieManager>();
|
||||
}
|
||||
|
||||
const WebResourceCachePtr& getWebResourceCache() const { return mWebResourceCache; }
|
||||
|
||||
@@ -1057,7 +1068,7 @@ class EE_API UISceneNode : public SceneNode {
|
||||
URI mURI;
|
||||
URI mReferer;
|
||||
std::function<bool( const NavigationRequest& request )> mNavigationInterceptorCb;
|
||||
Network::CookieManager mCookieManager;
|
||||
std::shared_ptr<CookieManager> mCookieManager{ std::make_shared<CookieManager>() };
|
||||
|
||||
/**
|
||||
* @brief Protected constructor.
|
||||
|
||||
@@ -52,6 +52,7 @@ class EE_API UIScrollView : public UITouchDraggableWidget {
|
||||
|
||||
void setAnchorScroll( bool anchor );
|
||||
|
||||
/** Enable default scrolling shortcuts. Enabled by default. */
|
||||
void setEnableDefaultKeybindings( bool enable );
|
||||
|
||||
bool areDefaultKeybindingsEnabled() const { return mDefaultKeybindings; }
|
||||
@@ -92,6 +93,10 @@ class EE_API UIScrollView : public UITouchDraggableWidget {
|
||||
|
||||
virtual Uint32 onKeyDown( const KeyEvent& event );
|
||||
|
||||
virtual Uint32 onTextInput( const TextInputEvent& event );
|
||||
|
||||
void scrollByViewport( Float direction );
|
||||
|
||||
void onValueChangeCb( const Event* Event );
|
||||
|
||||
virtual void onScrollViewSizeChange( const Event* Event );
|
||||
|
||||
@@ -201,6 +201,8 @@ class EE_API UITextInput : public UITextView, public TextDocument::Client {
|
||||
|
||||
virtual void onFontStyleChanged();
|
||||
|
||||
virtual void onFontColorChanged();
|
||||
|
||||
void onThemeLoaded();
|
||||
|
||||
virtual void onCursorPosChange();
|
||||
|
||||
@@ -179,6 +179,8 @@ class EE_API UITextView : public UIWidget {
|
||||
|
||||
virtual void onFontStyleChanged();
|
||||
|
||||
virtual void onFontColorChanged();
|
||||
|
||||
virtual void onAlphaChange();
|
||||
|
||||
virtual void onPaddingChange();
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
|
||||
using namespace EE::Network;
|
||||
|
||||
namespace EE { namespace Network {
|
||||
class CookieManager;
|
||||
}} // namespace EE::Network
|
||||
|
||||
namespace EE { namespace UI {
|
||||
|
||||
class UIHTMLHtml;
|
||||
@@ -26,6 +30,31 @@ class UISceneNode;
|
||||
|
||||
class EE_API UIWebView : public UIScrollView {
|
||||
public:
|
||||
/** The icon is empty when the replacement document has no favicon yet. */
|
||||
struct FaviconEvent : Scene::Event {
|
||||
TexturePtr icon;
|
||||
|
||||
FaviconEvent( Node* node, TexturePtr icon = {} ) :
|
||||
Scene::Event( node, Event::OnFaviconChanged ), icon( std::move( icon ) ) {}
|
||||
};
|
||||
|
||||
struct TitleEvent : Scene::Event {
|
||||
std::string title;
|
||||
|
||||
TitleEvent( Node* node, std::string title ) :
|
||||
Scene::Event( node, Event::OnTitleChanged ), title( std::move( title ) ) {}
|
||||
};
|
||||
|
||||
struct LinkOpenEvent : Scene::Event {
|
||||
URI uri;
|
||||
mutable bool handled{ false };
|
||||
|
||||
LinkOpenEvent( Node* node, URI uri ) :
|
||||
Scene::Event( node, Event::OnLinkOpenRequested ), uri( std::move( uri ) ) {}
|
||||
|
||||
void accept() const { handled = true; }
|
||||
};
|
||||
|
||||
struct NavigationEvent : Scene::Event {
|
||||
URI uri;
|
||||
bool success{ false };
|
||||
@@ -72,6 +101,8 @@ class EE_API UIWebView : public UIScrollView {
|
||||
|
||||
const URI& getCurrentURI() const;
|
||||
|
||||
const std::string& getTitle() const { return mTitle; }
|
||||
|
||||
void reload();
|
||||
|
||||
UIWidget* getDocumentContainer() const;
|
||||
@@ -82,6 +113,9 @@ class EE_API UIWebView : public UIScrollView {
|
||||
|
||||
UIWebView* setWebResourceCache( WebResourceCachePtr cache, CachePartitionId partition = 0 );
|
||||
|
||||
/** Share a cookie jar with other WebViews. Set before starting navigation. */
|
||||
UIWebView* setCookieManager( std::shared_ptr<EE::Network::CookieManager> manager );
|
||||
|
||||
void setStyleSheetDefaultMarker( Uint32 marker );
|
||||
|
||||
void setUserAgent( const std::string& userAgent );
|
||||
@@ -127,6 +161,7 @@ class EE_API UIWebView : public UIScrollView {
|
||||
bool mIsLoading{ false };
|
||||
Uint64 mNavigationGeneration{ 0 };
|
||||
std::string mUserAgent;
|
||||
std::string mTitle;
|
||||
Time mDefaultTimeout{ Seconds( 30 ) };
|
||||
Time mWebResourceCachePruneElapsed;
|
||||
Uint32 mStyleSheetDefaultMarker{ 0 };
|
||||
@@ -141,6 +176,7 @@ class EE_API UIWebView : public UIScrollView {
|
||||
virtual void scheduledUpdate( const Time& time );
|
||||
|
||||
virtual Uint32 onKeyDown( const KeyEvent& event );
|
||||
|
||||
virtual Uint32 onMessage( const NodeMessage* message );
|
||||
virtual void onScrollViewSizeChange( const Event* event );
|
||||
|
||||
|
||||
@@ -110,7 +110,9 @@ def command(args):
|
||||
params["format"] = args.format
|
||||
return "ui.screenshot", params
|
||||
if name == "click":
|
||||
return "input.click", target(args.target, args.scene)
|
||||
params = target(args.target, args.scene)
|
||||
params["button"] = args.button
|
||||
return "input.click", params
|
||||
if name == "key":
|
||||
params.update(key=args.key, action=args.action, modifiers=args.modifiers)
|
||||
return "input.key", params
|
||||
@@ -168,6 +170,7 @@ def main():
|
||||
click = sub.add_parser("click")
|
||||
click.add_argument("target")
|
||||
click.add_argument("--scene")
|
||||
click.add_argument("--button", choices=("left", "middle", "right"), default="left")
|
||||
key = sub.add_parser("key")
|
||||
key.add_argument("key")
|
||||
key.add_argument("--scene")
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
#include <eepp/network/cookiemanager.hpp>
|
||||
#include <eepp/system/filesystem.hpp>
|
||||
#include <eepp/system/lock.hpp>
|
||||
#include <fstream>
|
||||
#include <iomanip>
|
||||
|
||||
namespace EE { namespace Network {
|
||||
|
||||
CookieManager::CookieManager() {}
|
||||
|
||||
void CookieManager::storeCookies( const std::string& domain,
|
||||
const Http::Response& response ) {
|
||||
void CookieManager::storeCookies( const std::string& domain, const Http::Response& response ) {
|
||||
std::string setCookie = response.getField( "set-cookie" );
|
||||
if ( !setCookie.empty() )
|
||||
parseSetCookie( domain, setCookie );
|
||||
@@ -38,6 +40,61 @@ void CookieManager::clear() {
|
||||
mCookies.clear();
|
||||
}
|
||||
|
||||
void CookieManager::clearDomain( const std::string& domain ) {
|
||||
Lock l( mMutex );
|
||||
mCookies.erase( domain );
|
||||
}
|
||||
|
||||
bool CookieManager::loadFromFile( const std::string& path ) {
|
||||
std::ifstream file( path, std::ios::binary );
|
||||
if ( !file )
|
||||
return false;
|
||||
std::string version;
|
||||
if ( !( file >> version ) || version != "eepp-cookies-1" )
|
||||
return false;
|
||||
UnorderedMap<std::string, std::map<std::string, std::string>> cookies;
|
||||
std::string domain, name, value;
|
||||
while ( file >> std::quoted( domain ) ) {
|
||||
if ( !( file >> std::quoted( name ) >> std::quoted( value ) ) || domain.empty() ||
|
||||
name.empty() )
|
||||
return false;
|
||||
cookies[domain][name] = value;
|
||||
}
|
||||
if ( !file.eof() )
|
||||
return false;
|
||||
Lock l( mMutex );
|
||||
mCookies = std::move( cookies );
|
||||
return true;
|
||||
}
|
||||
|
||||
bool CookieManager::saveToFile( const std::string& path ) const {
|
||||
const std::string temporaryPath = path + ".tmp";
|
||||
{
|
||||
std::ofstream file( temporaryPath, std::ios::binary | std::ios::trunc );
|
||||
if ( !file )
|
||||
return false;
|
||||
file << "eepp-cookies-1\n";
|
||||
{
|
||||
Lock l( mMutex );
|
||||
for ( const auto& domain : mCookies ) {
|
||||
for ( const auto& cookie : domain.second )
|
||||
file << std::quoted( domain.first ) << ' ' << std::quoted( cookie.first ) << ' '
|
||||
<< std::quoted( cookie.second ) << '\n';
|
||||
}
|
||||
}
|
||||
file.flush();
|
||||
if ( !file ) {
|
||||
FileSystem::fileRemove( temporaryPath );
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if ( !FileSystem::fileMove( temporaryPath, path ) ) {
|
||||
FileSystem::fileRemove( temporaryPath );
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
size_t CookieManager::size() const {
|
||||
Lock l( mMutex );
|
||||
size_t total = 0;
|
||||
@@ -73,6 +130,7 @@ void CookieManager::parseSetCookie( const std::string& domain,
|
||||
}
|
||||
|
||||
bool CookieManager::hasCookie( const std::string& domain ) const {
|
||||
Lock l( mMutex );
|
||||
return mCookies.find( domain ) != mCookies.end();
|
||||
}
|
||||
|
||||
|
||||
@@ -1061,6 +1061,7 @@ Http::Response Http::downloadRequest( const Http::Request& request, IOStream& wr
|
||||
// redirections, send a new request to the redirection location.
|
||||
if ( ( received.getStatus() == Response::MovedPermanently ||
|
||||
received.getStatus() == Response::MovedTemporarily ||
|
||||
received.getStatus() == Response::SeeOther ||
|
||||
received.getStatus() == Response::PermanentRedirect ||
|
||||
received.getStatus() == Response::TemporaryRedirect ) &&
|
||||
request.getFollowRedirect() ) {
|
||||
@@ -1094,9 +1095,38 @@ Http::Response Http::downloadRequest( const Http::Request& request, IOStream& wr
|
||||
newRequest.setProgressCallback(
|
||||
request.getProgressCallback() );
|
||||
|
||||
if ( received.hasField( "set-cookie" ) ) {
|
||||
newRequest.setField(
|
||||
"Cookie", received.getField( "set-cookie" ) );
|
||||
if ( received.hasField( "set-cookie" ) &&
|
||||
( uri.getHost().empty() ||
|
||||
uri.getHost() == getHostName() ) ) {
|
||||
const std::string& setCookie =
|
||||
received.getField( "set-cookie" );
|
||||
std::string_view cookiePair = String::trim(
|
||||
std::string_view( setCookie )
|
||||
.substr( 0, setCookie.find( ';' ) ) );
|
||||
const size_t equals = cookiePair.find( '=' );
|
||||
if ( equals != std::string::npos && equals != 0 ) {
|
||||
std::string cookieHeader( cookiePair );
|
||||
std::string_view cookieName =
|
||||
cookiePair.substr( 0, equals );
|
||||
const std::string& previous =
|
||||
request.getField( "cookie" );
|
||||
String::readBySeparator(
|
||||
previous,
|
||||
[&]( std::string_view part ) {
|
||||
std::string_view pair =
|
||||
String::trim( part );
|
||||
if ( !pair.empty() &&
|
||||
pair.substr( 0,
|
||||
pair.find( '=' ) ) !=
|
||||
cookieName ) {
|
||||
cookieHeader += "; ";
|
||||
cookieHeader.append( pair.data(),
|
||||
pair.size() );
|
||||
}
|
||||
},
|
||||
';' );
|
||||
newRequest.setField( "Cookie", cookieHeader );
|
||||
}
|
||||
}
|
||||
|
||||
request.mRedirectionCount++;
|
||||
|
||||
@@ -186,7 +186,7 @@ void EventDispatcher::update( const Time& time ) {
|
||||
}
|
||||
|
||||
if ( mInput->getReleaseTrigger() ) {
|
||||
if ( NULL != mFocusNode ) {
|
||||
if ( NULL != mFocusNode || ( mInput->getReleaseTrigger() & EE_BUTTON_MMASK ) ) {
|
||||
if ( !nodeWasDragging || mMousePos == mLastMousePos ) {
|
||||
// The focused node can change after the MouseUp ( since the node can call
|
||||
// "setFocus()" on other node And the MouseClick would be received by the new
|
||||
@@ -208,14 +208,21 @@ void EventDispatcher::update( const Time& time ) {
|
||||
}
|
||||
|
||||
if ( mInput->getClickTrigger() && mDownNode == mOverNode ) {
|
||||
mLastFocusNode->onMouseClick( mMousePosi, mInput->getClickTrigger() );
|
||||
sendMsg( mLastFocusNode, NodeMessage::MouseClick, mInput->getClickTrigger() );
|
||||
// Middle click should activate the hovered widget without changing keyboard
|
||||
// focus.
|
||||
Node* clickNode = ( mInput->getClickTrigger() & EE_BUTTON_MMASK )
|
||||
? mOverNode
|
||||
: mLastFocusNode;
|
||||
if ( clickNode ) {
|
||||
clickNode->onMouseClick( mMousePosi, mInput->getClickTrigger() );
|
||||
sendMsg( clickNode, NodeMessage::MouseClick, mInput->getClickTrigger() );
|
||||
}
|
||||
|
||||
if ( mInput->getDoubleClickTrigger() &&
|
||||
if ( clickNode && mInput->getDoubleClickTrigger() &&
|
||||
mClickPos.distance( mMousePosi ) < 10 ) {
|
||||
mLastFocusNode->onMouseDoubleClick( mMousePosi,
|
||||
mInput->getDoubleClickTrigger() );
|
||||
sendMsg( mLastFocusNode, NodeMessage::MouseDoubleClick,
|
||||
clickNode->onMouseDoubleClick( mMousePosi,
|
||||
mInput->getDoubleClickTrigger() );
|
||||
sendMsg( clickNode, NodeMessage::MouseDoubleClick,
|
||||
mInput->getDoubleClickTrigger() );
|
||||
}
|
||||
|
||||
|
||||
@@ -352,7 +352,7 @@ struct UIInspectorServer::Impl {
|
||||
: "unauthenticated";
|
||||
const char* message =
|
||||
request.is_discarded() ? "Malformed JSON"
|
||||
: isConnect ? "Invalid inspector token"
|
||||
: isConnect ? "Invalid inspector token"
|
||||
: "First request must be session.connect";
|
||||
client.output +=
|
||||
json( { { "id", id },
|
||||
@@ -1068,8 +1068,14 @@ struct UIInspectorServer::Impl {
|
||||
const std::string windowHandle = inspector.windowHandle( window );
|
||||
if ( method == "input.click" ) {
|
||||
const std::string targetHandle = inspector.widgetHandle( target );
|
||||
if ( params.contains( "button" ) && params["button"] != "left" )
|
||||
fail( "invalid-params", "Only left click is supported" );
|
||||
if ( params.contains( "button" ) && !params["button"].is_string() )
|
||||
fail( "invalid-params", "button must be a string" );
|
||||
const std::string button = params.value( "button", "left" );
|
||||
if ( button != "left" && button != "middle" && button != "right" )
|
||||
fail( "invalid-params", "button must be left, middle, or right" );
|
||||
const Uint8 inputButton = button == "right" ? EE_BUTTON_RIGHT
|
||||
: button == "middle" ? EE_BUTTON_MIDDLE
|
||||
: EE_BUTTON_LEFT;
|
||||
unsigned count = boundedUnsigned( params, "count", 1, 2 );
|
||||
auto bounds = target->getWorldBounds();
|
||||
Vector2i point( static_cast<int>( ( bounds.Left + bounds.Right ) * 0.5f ),
|
||||
@@ -1102,7 +1108,7 @@ struct UIInspectorServer::Impl {
|
||||
InputEvent press( InputEvent::MouseButtonDown );
|
||||
press.WinID = window->getWindowID();
|
||||
press.button = {};
|
||||
press.button.button = EE_BUTTON_LEFT;
|
||||
press.button.button = inputButton;
|
||||
press.button.x = point.x;
|
||||
press.button.y = point.y;
|
||||
input->pushEvent( press );
|
||||
|
||||
@@ -180,8 +180,11 @@ UITabWidget* UITabWidgetSplitter::createTabWidget( Node* parent ) {
|
||||
} );
|
||||
if ( mOnTabWidgetCreateCb )
|
||||
mOnTabWidgetCreateCb( tabWidget );
|
||||
Lock l( mTabWidgetMutex );
|
||||
mTabWidgets.push_back( tabWidget );
|
||||
{
|
||||
Lock l( mTabWidgetMutex );
|
||||
mTabWidgets.push_back( tabWidget );
|
||||
}
|
||||
updateTabBarVisibility();
|
||||
return tabWidget;
|
||||
}
|
||||
|
||||
@@ -691,6 +694,7 @@ void UITabWidgetSplitter::onTabClosed( const TabEvent* tabEvent ) {
|
||||
focusSomeWidget( nullptr );
|
||||
}
|
||||
|
||||
updateTabBarVisibility();
|
||||
eeASSERT( !mTabWidgets.empty() );
|
||||
eeASSERT( !( mTabWidgets.size() == 1 && mTabWidgets[0]->getTabCount() == 0 ) );
|
||||
return;
|
||||
@@ -771,12 +775,27 @@ bool UITabWidgetSplitter::getHideTabBarOnSingleTab() const {
|
||||
return mHideTabBarOnSingleTab;
|
||||
}
|
||||
|
||||
void UITabWidgetSplitter::updateTabBarVisibility() {
|
||||
const bool hideSingleTabBar = mHideTabBarOnSingleTab && !( mShowTabBarWhenSplit && hasSplit() );
|
||||
for ( auto* widget : mTabWidgets )
|
||||
widget->setHideTabBarOnSingleTab( hideSingleTabBar );
|
||||
}
|
||||
|
||||
void UITabWidgetSplitter::setHideTabBarOnSingleTab( bool hideTabBarOnSingleTab ) {
|
||||
if ( hideTabBarOnSingleTab != mHideTabBarOnSingleTab ) {
|
||||
mHideTabBarOnSingleTab = hideTabBarOnSingleTab;
|
||||
updateTabBarVisibility();
|
||||
}
|
||||
}
|
||||
|
||||
for ( auto widget : mTabWidgets )
|
||||
widget->setHideTabBarOnSingleTab( hideTabBarOnSingleTab );
|
||||
bool UITabWidgetSplitter::getShowTabBarWhenSplit() const {
|
||||
return mShowTabBarWhenSplit;
|
||||
}
|
||||
|
||||
void UITabWidgetSplitter::setShowTabBarWhenSplit( bool showTabBarWhenSplit ) {
|
||||
if ( mShowTabBarWhenSplit != showTabBarWhenSplit ) {
|
||||
mShowTabBarWhenSplit = showTabBarWhenSplit;
|
||||
updateTabBarVisibility();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1195,7 +1195,7 @@ Uint32 UICodeEditor::onTextInput( const TextInputEvent& event ) {
|
||||
if ( plugin->onTextInput( this, event ) )
|
||||
return 1;
|
||||
|
||||
return 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
void UICodeEditor::updateIMELocation() {
|
||||
@@ -1920,8 +1920,8 @@ Uint32 UICodeEditor::onMouseUp( const Vector2i& position, const Uint32& flags )
|
||||
}
|
||||
|
||||
Uint32 UICodeEditor::onMouseWheel( const Vector2f& offset, bool flipped ) {
|
||||
const Vector2i position = getEventDispatcher() ? getEventDispatcher()->getMousePos()
|
||||
: Vector2i::Zero;
|
||||
const Vector2i position =
|
||||
getEventDispatcher() ? getEventDispatcher()->getMousePos() : Vector2i::Zero;
|
||||
for ( auto& plugin : mPlugins )
|
||||
if ( plugin->onMouseWheel( this, position, offset, flipped ) )
|
||||
return 1;
|
||||
|
||||
@@ -83,7 +83,9 @@ void UIHTMLForm::submit() {
|
||||
UISceneNode* sceneNode = getUISceneNode();
|
||||
|
||||
NavigationRequest request;
|
||||
request.uri = URI( mAction );
|
||||
// With no action, HTML submits to the current document URL, not its asset base URI.
|
||||
request.uri = mAction.empty() ? sceneNode->getReferer() : URI( mAction );
|
||||
request.source = this;
|
||||
|
||||
if ( mEnctype == "multipart/form-data" ) {
|
||||
request.method = "POST";
|
||||
|
||||
@@ -919,7 +919,7 @@ void UISceneNode::requestWebResource( WebResourceRequest request,
|
||||
return;
|
||||
if ( !mReferer.empty() )
|
||||
request.headers.emplace( "referer", mReferer.toString() );
|
||||
std::string cookie = mCookieManager.getCookieHeader( request.uri.getAuthority() );
|
||||
std::string cookie = getCookieManager().getCookieHeader( request.uri.getAuthority() );
|
||||
if ( !cookie.empty() )
|
||||
request.headers["Cookie"] = std::move( cookie );
|
||||
auto resourceState = mAsyncResourceLoadState;
|
||||
@@ -933,7 +933,7 @@ void UISceneNode::requestWebResource( WebResourceRequest request,
|
||||
if ( !scene )
|
||||
return;
|
||||
if ( !result.setCookie.empty() )
|
||||
scene->mCookieManager.storeCookiesFromHeader( authority, result.setCookie );
|
||||
scene->getCookieManager().storeCookiesFromHeader( authority, result.setCookie );
|
||||
if ( callback )
|
||||
callback( result );
|
||||
};
|
||||
@@ -947,7 +947,7 @@ TexturePtr UISceneNode::requestWebTexture( WebResourceRequest request,
|
||||
return {};
|
||||
if ( !mReferer.empty() )
|
||||
request.headers.emplace( "referer", mReferer.toString() );
|
||||
std::string cookie = mCookieManager.getCookieHeader( request.uri.getAuthority() );
|
||||
std::string cookie = getCookieManager().getCookieHeader( request.uri.getAuthority() );
|
||||
if ( !cookie.empty() )
|
||||
request.headers["Cookie"] = std::move( cookie );
|
||||
auto resourceState = mAsyncResourceLoadState;
|
||||
@@ -967,7 +967,7 @@ TexturePtr UISceneNode::requestWebTexture( WebResourceRequest request,
|
||||
if ( !scene )
|
||||
return;
|
||||
if ( !result.setCookie.empty() )
|
||||
scene->mCookieManager.storeCookiesFromHeader( authority, result.setCookie );
|
||||
scene->getCookieManager().storeCookiesFromHeader( authority, result.setCookie );
|
||||
if ( callback )
|
||||
callback( result );
|
||||
};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
#include <cmath>
|
||||
#include <eepp/ui/css/propertydefinition.hpp>
|
||||
#include <eepp/ui/uiscenenode.hpp>
|
||||
#include <eepp/ui/uiscrollbar.hpp>
|
||||
#include <eepp/ui/uiscrollview.hpp>
|
||||
#include <eepp/window/input.hpp>
|
||||
#include <eepp/window/window.hpp>
|
||||
|
||||
namespace EE { namespace UI {
|
||||
|
||||
@@ -488,6 +490,10 @@ void UIScrollView::setScrollControllerPosition( const Vector2f& position ) {
|
||||
mVScroll->setValue( maxPosition.y > 0.f ? position.y / maxPosition.y : 0.f );
|
||||
}
|
||||
|
||||
void UIScrollView::setEnableDefaultKeybindings( bool enable ) {
|
||||
mDefaultKeybindings = enable;
|
||||
}
|
||||
|
||||
bool UIScrollView::isAutoSetClipStep() const {
|
||||
return mAutoSetClipStep;
|
||||
}
|
||||
@@ -505,7 +511,16 @@ void UIScrollView::setAnchorScroll( bool anchor ) {
|
||||
}
|
||||
|
||||
Uint32 UIScrollView::onKeyDown( const KeyEvent& event ) {
|
||||
if ( !mDefaultKeybindings || event.getSanitizedMod() )
|
||||
const auto mod = event.getSanitizedMod();
|
||||
if ( mDefaultKeybindings && event.getKeyCode() == Window::KEY_SPACE &&
|
||||
( mod & ~KEYMOD_SHIFT ) == 0 ) {
|
||||
// Active text input gets priority through onTextInput's normal bubbling path.
|
||||
if ( getUISceneNode()->getWindow()->isTextInputActive() )
|
||||
return UITouchDraggableWidget::onKeyDown( event );
|
||||
scrollByViewport( mod & KEYMOD_SHIFT ? -1.f : 1.f );
|
||||
return 1;
|
||||
}
|
||||
if ( !mDefaultKeybindings || mod )
|
||||
return UITouchDraggableWidget::onKeyDown( event );
|
||||
|
||||
if ( event.getKeyCode() == Window::KEY_UP ) {
|
||||
@@ -515,10 +530,10 @@ Uint32 UIScrollView::onKeyDown( const KeyEvent& event ) {
|
||||
mVScroll->setValue( mVScroll->getValue() + mVScroll->getClickStep() );
|
||||
return 1;
|
||||
} else if ( event.getKeyCode() == Window::KEY_PAGEDOWN ) {
|
||||
mVScroll->setValue( mVScroll->getValue() + mVScroll->getPageStep() );
|
||||
scrollByViewport( 1.f );
|
||||
return 1;
|
||||
} else if ( event.getKeyCode() == Window::KEY_PAGEUP ) {
|
||||
mVScroll->setValue( mVScroll->getValue() - mVScroll->getPageStep() );
|
||||
scrollByViewport( -1.f );
|
||||
return 1;
|
||||
} else if ( event.getKeyCode() == Window::KEY_HOME ) {
|
||||
mVScroll->setValue( mVScroll->getMinValue() );
|
||||
@@ -531,4 +546,20 @@ Uint32 UIScrollView::onKeyDown( const KeyEvent& event ) {
|
||||
return UITouchDraggableWidget::onKeyDown( event );
|
||||
}
|
||||
|
||||
Uint32 UIScrollView::onTextInput( const TextInputEvent& event ) {
|
||||
if ( mDefaultKeybindings && event.getChar() == ' ' && event.isValid( getInput() ) ) {
|
||||
scrollByViewport( getInput()->isShiftPressed() ? -1.f : 1.f );
|
||||
return 1;
|
||||
}
|
||||
return UITouchDraggableWidget::onTextInput( event );
|
||||
}
|
||||
|
||||
void UIScrollView::scrollByViewport( Float direction ) {
|
||||
const Float range = getScrollControllerMaxPosition().y;
|
||||
if ( mVScroll->isEnabled() && range > 0.f ) {
|
||||
mVScroll->setValue( mVScroll->getValue() +
|
||||
direction * mContainer->getPixelsSize().getHeight() / range );
|
||||
}
|
||||
}
|
||||
|
||||
}} // namespace EE::UI
|
||||
|
||||
@@ -406,6 +406,11 @@ void UITextInput::onFontStyleChanged() {
|
||||
invalidateDraw();
|
||||
}
|
||||
|
||||
void UITextInput::onFontColorChanged() {
|
||||
if ( mPassCache )
|
||||
mPassCache->setFillColor( mTextCache.getFillColor() );
|
||||
}
|
||||
|
||||
Text& UITextInput::getVisibleTextCache() {
|
||||
if ( mMode == TextInputMode::Password && mPassCache )
|
||||
return *mPassCache;
|
||||
|
||||
@@ -810,7 +810,7 @@ bool UITextSelectionController::showContextMenu( const Vector2i& position, Uint3
|
||||
}
|
||||
auto* selectAllItem = menu->add( mHost->i18n( "uicodeeditor_select_all", "Select All" ) );
|
||||
selectAllItem->setId( "select-all" );
|
||||
ContextMenuEvent event( mHost, menu, Event::OnCreateContextMenu, position, flags );
|
||||
ContextMenuEvent event( mHost, menu, Event::OnCreateContextMenu, position, flags, target );
|
||||
mHost->sendEvent( &event );
|
||||
if ( menu->getCount() == 0 ) {
|
||||
menu->close();
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include <eepp/ui/uitextspan.hpp>
|
||||
#include <eepp/ui/uithememanager.hpp>
|
||||
#include <eepp/ui/uiwidgetcreator.hpp>
|
||||
#include <eepp/window/input.hpp>
|
||||
|
||||
#define PUGIXML_HEADER_ONLY
|
||||
#include <pugixml/pugixml.hpp>
|
||||
@@ -765,8 +766,13 @@ Uint32 UIAnchorSpan::onMessage( const NodeMessage* Msg ) {
|
||||
if ( controller->consumeSuppressedClick() )
|
||||
return 1;
|
||||
}
|
||||
if ( !mHref.empty() && ( Msg->getFlags() & EE_BUTTON_LMASK ) )
|
||||
getUISceneNode()->openURL( mHref );
|
||||
if ( !mHref.empty() && ( Msg->getFlags() & ( EE_BUTTON_LMASK | EE_BUTTON_MMASK ) ) ) {
|
||||
NavigationRequest request{ URI( mHref ) };
|
||||
request.source = this;
|
||||
request.mouseButtons = Msg->getFlags();
|
||||
request.modifiers = getInput() ? getInput()->getModState() : 0;
|
||||
getUISceneNode()->navigate( request );
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,6 +306,7 @@ UITextView* UITextView::setFontColor( const Color& color ) {
|
||||
mFontStyleConfig.FontColor = color;
|
||||
Color newColor( color.r, color.g, color.b, color.a * mAlpha / 255.f );
|
||||
mTextCache.setFillColor( newColor );
|
||||
onFontColorChanged();
|
||||
invalidateDraw();
|
||||
}
|
||||
|
||||
@@ -527,6 +528,8 @@ void UITextView::onFontStyleChanged() {
|
||||
invalidateDraw();
|
||||
}
|
||||
|
||||
void UITextView::onFontColorChanged() {}
|
||||
|
||||
void UITextView::onAlphaChange() {
|
||||
Color color( getFontColor() );
|
||||
Color newColor( color.r, color.g, color.b, color.a * mAlpha / 255.f );
|
||||
@@ -649,7 +652,7 @@ void UITextView::drawSelection( Text& textCache ) {
|
||||
mSelRectsCache.clear();
|
||||
mLastSelCurInit = selCurInit();
|
||||
mLastSelCurEnd = selCurEnd();
|
||||
mSelRectsCache = mTextCache.getSelectionRects( { selCurInit(), selCurEnd() } );
|
||||
mSelRectsCache = textCache.getSelectionRects( { selCurInit(), selCurEnd() } );
|
||||
}
|
||||
|
||||
if ( !mSelRectsCache.empty() ) {
|
||||
|
||||
+86
-17
@@ -10,6 +10,10 @@
|
||||
#include <eepp/ui/uiscrollbar.hpp>
|
||||
#include <eepp/ui/uistyle.hpp>
|
||||
#include <eepp/ui/uiwebview.hpp>
|
||||
#include <eepp/window/keycodes.hpp>
|
||||
|
||||
#define PUGIXML_HEADER_ONLY
|
||||
#include <pugixml/pugixml.hpp>
|
||||
|
||||
namespace EE { namespace UI {
|
||||
|
||||
@@ -168,6 +172,30 @@ UIWebView::UIWebView() : UIScrollView( "webview" ) {
|
||||
mDocumentLayout->setParent( this );
|
||||
|
||||
mDocumentScene = UISceneNode::New();
|
||||
std::weak_ptr<NavigationLoadState> loadState( mNavigationLoadState );
|
||||
mDocumentScene->setNavigationInterceptorCb( [loadState]( const NavigationRequest& request ) {
|
||||
auto locked = loadState.lock();
|
||||
if ( !locked || !locked->alive || locked->owner == nullptr )
|
||||
return true;
|
||||
UIWebView* self = locked->owner;
|
||||
UISceneNode* docScene = self->getDocumentSceneNode();
|
||||
if ( !docScene )
|
||||
return true;
|
||||
URI uri = docScene->solveRelativePath( request.uri );
|
||||
const bool requestNewTab = request.target == NavigationRequest::Target::NewTab ||
|
||||
( request.mouseButtons & EE_BUTTON_MMASK ) ||
|
||||
( ( request.mouseButtons & EE_BUTTON_LMASK ) &&
|
||||
( request.modifiers & KeyMod::getDefaultModifier() ) );
|
||||
if ( requestNewTab ) {
|
||||
LinkOpenEvent event( self, uri );
|
||||
self->sendEvent( &event );
|
||||
if ( event.handled )
|
||||
return true;
|
||||
}
|
||||
self->loadURI( uri, request.method != "GET", request.method, request.body,
|
||||
request.extraHeaders );
|
||||
return true;
|
||||
} );
|
||||
mDocumentScene->setFollowParentSize( false );
|
||||
mDocumentScene->setVisibleBoundsNode( mContainer );
|
||||
mDocumentScene->setParent( mDocumentLayout );
|
||||
@@ -290,6 +318,7 @@ void UIWebView::loadURI( URI uri, bool isHistoryNav, const std::string& method,
|
||||
const std::string& body, const Http::Request::FieldTable& headers ) {
|
||||
Uint64 generation = beginNavigationLoad();
|
||||
mIsLoading = true;
|
||||
mTitle.clear();
|
||||
|
||||
if ( !isHistoryNav )
|
||||
pushHistory( uri );
|
||||
@@ -415,32 +444,66 @@ void UIWebView::loadDocumentData( URI url, std::string data, Uint64 generation )
|
||||
ui->getStyleSheet().removeAllWithoutMarker( self->mStyleSheetDefaultMarker );
|
||||
ui->setURIFromURL( url );
|
||||
|
||||
std::string documentXML = Tools::HTMLFormatter::HTMLtoXML( data );
|
||||
pugi::xml_document metadataDocument;
|
||||
if ( metadataDocument.load_string( documentXML.c_str() ) ) {
|
||||
self->mTitle = String::trim( std::string_view(
|
||||
metadataDocument.child( "html" ).child( "head" ).child( "title" ).child_value() ) );
|
||||
} else {
|
||||
self->mTitle.clear();
|
||||
}
|
||||
auto hash = String::hash( url.toString() );
|
||||
ui->loadLayoutFromString( Tools::HTMLFormatter::HTMLtoXML( data ), self->mDocContainer,
|
||||
hash );
|
||||
ui->loadLayoutFromString( documentXML, self->mDocContainer, hash );
|
||||
self->mTextSelectionController.onDocumentChanged();
|
||||
|
||||
ui->setNavigationInterceptorCb( [loadState]( const NavigationRequest& request ) {
|
||||
auto locked = loadState.lock();
|
||||
if ( !locked || !locked->alive || locked->owner == nullptr )
|
||||
return true;
|
||||
UIWebView* self = locked->owner;
|
||||
UISceneNode* docScene = self->getDocumentSceneNode();
|
||||
if ( !docScene )
|
||||
return true;
|
||||
URI uri = docScene->solveRelativePath( request.uri );
|
||||
self->loadURI( uri, request.method != "GET", request.method, request.body,
|
||||
request.extraHeaders );
|
||||
return true;
|
||||
} );
|
||||
|
||||
if ( !self->isNavigationLoadCurrent( generation ) )
|
||||
return;
|
||||
self->mIsLoading = false;
|
||||
NavigationEvent ev( self, (Uint32)Event::OnNavigationCompleted, url, true );
|
||||
self->sendEvent( &ev );
|
||||
TitleEvent titleEvent( self, self->mTitle );
|
||||
self->sendEvent( &titleEvent );
|
||||
self->markDocumentExtentDirty( LayoutInvalidation::Document );
|
||||
self->updateDocumentMetricsIfNeeded();
|
||||
|
||||
FaviconEvent cleared( self );
|
||||
self->sendEvent( &cleared );
|
||||
if ( !self->isNavigationLoadCurrent( generation ) )
|
||||
return;
|
||||
for ( auto link : metadataDocument.child( "html" ).child( "head" ).children( "link" ) ) {
|
||||
std::string_view tokens( link.attribute( "rel" ).value() );
|
||||
bool isIcon = false;
|
||||
while ( !tokens.empty() ) {
|
||||
const size_t start = tokens.find_first_not_of( " \t\r\n\f" );
|
||||
if ( start == std::string_view::npos )
|
||||
break;
|
||||
tokens.remove_prefix( start );
|
||||
const size_t end = tokens.find_first_of( " \t\r\n\f" );
|
||||
if ( String::iequals( tokens.substr( 0, end ), "icon" ) ) {
|
||||
isIcon = true;
|
||||
break;
|
||||
}
|
||||
if ( end == std::string_view::npos )
|
||||
break;
|
||||
tokens.remove_prefix( end );
|
||||
}
|
||||
const char* href = link.attribute( "href" ).value();
|
||||
if ( !isIcon || !*href )
|
||||
continue;
|
||||
WebResourceRequest request;
|
||||
request.uri = ui->solveRelativePath( URI( href ) );
|
||||
request.kind = WebResourceKind::Image;
|
||||
request.timeout = self->mDefaultTimeout;
|
||||
ui->requestWebTexture( std::move( request ),
|
||||
[loadState, generation]( const WebResourceResult& result ) {
|
||||
auto* view = resolveNavigationLoad( loadState, generation );
|
||||
if ( !view || !result.success || !result.texture )
|
||||
return;
|
||||
FaviconEvent iconEvent( view, result.texture );
|
||||
view->sendEvent( &iconEvent );
|
||||
} );
|
||||
break;
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
@@ -532,6 +595,12 @@ UIWebView* UIWebView::setWebResourceCache( WebResourceCachePtr cache, CacheParti
|
||||
return this;
|
||||
}
|
||||
|
||||
UIWebView* UIWebView::setCookieManager( std::shared_ptr<CookieManager> manager ) {
|
||||
if ( mDocumentScene )
|
||||
mDocumentScene->setCookieManager( std::move( manager ) );
|
||||
return this;
|
||||
}
|
||||
|
||||
bool UIWebView::updateDocumentViewportMetrics() {
|
||||
if ( !mDocumentScene || !mDocumentLayout || !mDocContainer || mUpdatingDocumentViewportMetrics )
|
||||
return false;
|
||||
@@ -671,7 +740,7 @@ Uint32 UIWebView::onNavigationError( std::function<void( const URI&, const std::
|
||||
|
||||
Uint32 UIWebView::onTitleChanged( std::function<void( const std::string& )> cb ) {
|
||||
return on( Event::OnTitleChanged,
|
||||
[cb]( const Event* e ) { cb( static_cast<const NavigationEvent*>( e )->error ); } );
|
||||
[cb]( const Event* e ) { cb( static_cast<const TitleEvent*>( e )->title ); } );
|
||||
}
|
||||
|
||||
}} // namespace EE::UI
|
||||
|
||||
@@ -33,10 +33,10 @@ std::string makeRequestKey( CachePartitionId partition, const WebResourceRequest
|
||||
canonicalURI( request.uri ).c_str() );
|
||||
for ( const auto& header : request.headers ) {
|
||||
std::string name = String::toLower( header.first );
|
||||
// The partition identifies the cookie/authentication context. Cookie values evolve while a
|
||||
// document is being used, and Referer changes with navigation history; including either one
|
||||
// would duplicate an otherwise reusable resource on Back/Forward navigation.
|
||||
if ( name == "cookie" || name == "referer" )
|
||||
// Documents can change with authentication, while static resources remain reusable as
|
||||
// cookies evolve. Referer changes with navigation history but does not identify content.
|
||||
if ( ( name == "cookie" && request.kind != WebResourceKind::Document ) ||
|
||||
name == "referer" )
|
||||
continue;
|
||||
key += name;
|
||||
key += ':';
|
||||
|
||||
@@ -1,9 +1,38 @@
|
||||
#include <eepp/ee.hpp>
|
||||
#include <eepp/graphics/texturedrawable.hpp>
|
||||
|
||||
#include <args/args.hxx>
|
||||
#include <iostream>
|
||||
#include <unordered_map>
|
||||
|
||||
using namespace EE::UI::Tools;
|
||||
|
||||
struct BrowserTabs : UITabWidgetSplitter::Client {
|
||||
UITabWidgetSplitter* splitter{ nullptr };
|
||||
UITextInput* urlBar{ nullptr };
|
||||
UIPushButton* backBtn{ nullptr };
|
||||
UIPushButton* fwdBtn{ nullptr };
|
||||
|
||||
UIWebView* current() const {
|
||||
return splitter && splitter->getCurWidget() ? splitter->getCurWidget()->asType<UIWebView>()
|
||||
: nullptr;
|
||||
}
|
||||
|
||||
void updateNavigation() const {
|
||||
UIWebView* view = current();
|
||||
backBtn->setEnabled( view && view->canGoBack() );
|
||||
fwdBtn->setEnabled( view && view->canGoForward() );
|
||||
}
|
||||
|
||||
void onTabCreated( UITab*, UIWidget* ) override {}
|
||||
|
||||
void onWidgetFocusChange( UIWidget* widget ) override {
|
||||
auto* view = widget->asType<UIWebView>();
|
||||
urlBar->setText( view->getCurrentURI().toString() );
|
||||
updateNavigation();
|
||||
}
|
||||
};
|
||||
|
||||
EE_MAIN_FUNC int main( int argc, char** argv ) {
|
||||
std::shared_ptr<ThreadPool> threadPool(
|
||||
ThreadPool::createShared( eemax<int>( 4, Sys::getCPUCount() ) ) );
|
||||
@@ -103,6 +132,8 @@ EE_MAIN_FUNC int main( int argc, char** argv ) {
|
||||
|
||||
auto vbox = ui->loadLayoutFromString( R"xml(
|
||||
<style>
|
||||
#tabs_host TabWidget { max-tab-width: 200dp; }
|
||||
#tabs_host Tab > Tab::Text { text-overflow: ellipsis; }
|
||||
PushButton.webview_ui {
|
||||
border-top-color: transparent;
|
||||
border-right-color: transparent;
|
||||
@@ -127,33 +158,157 @@ EE_MAIN_FUNC int main( int argc, char** argv ) {
|
||||
<PushButton lw="26dp" id="refreshbtn" class="webview_ui" text="@string(refresh, Refresh)"
|
||||
icon="icon(refresh, 18dp)"
|
||||
text-as-fallback="true" />
|
||||
<PushButton lw="26dp" id="newtabbtn" class="webview_ui" text="New Tab"
|
||||
icon="icon(add, 18dp)" text-as-fallback="true" />
|
||||
<TextInput id="url_bar" layout_width="0" layout_weight="1"
|
||||
hint="@string(enter_address, Enter Address)" />
|
||||
</hbox>
|
||||
<WebView id="webview" layout_width="match_parent" layout_height="0" layout_weight="1" />
|
||||
<vbox id="tabs_host" layout_width="match_parent" layout_height="0" layout_weight="1" />
|
||||
</vbox>
|
||||
)xml",
|
||||
nullptr, app.getStyleSheetDefaultMarker() );
|
||||
|
||||
UIWebView* webView = vbox->find( "webview" )->asType<UIWebView>();
|
||||
webView->setStyleSheetDefaultMarker( app.getStyleSheetDefaultMarker() );
|
||||
|
||||
auto urlBar = ui->find( "url_bar" )->asType<UITextInput>();
|
||||
auto backBtn = ui->find( "backbtn" )->asType<UIPushButton>();
|
||||
auto fwdBtn = ui->find( "fwdbtn" )->asType<UIPushButton>();
|
||||
auto refreshBtn = ui->find( "refreshbtn" )->asType<UIPushButton>();
|
||||
auto newTabBtn = ui->find( "newtabbtn" )->asType<UIPushButton>();
|
||||
|
||||
auto updateNavButtons = [webView, backBtn, fwdBtn]() {
|
||||
backBtn->setEnabled( webView->canGoBack() );
|
||||
fwdBtn->setEnabled( webView->canGoForward() );
|
||||
const std::string configPath = Sys::getConfigPath( "eepp-ui-html" );
|
||||
const std::string cookiePath =
|
||||
configPath.empty() ? std::string() : configPath + FileSystem::getOSSlash() + "cookies.txt";
|
||||
auto cookies = std::make_shared<CookieManager>();
|
||||
if ( !cookiePath.empty() )
|
||||
cookies->loadFromFile( cookiePath );
|
||||
auto saveCookies = [&]() {
|
||||
if ( !cookiePath.empty() &&
|
||||
( FileSystem::isDirectory( configPath ) ||
|
||||
FileSystem::makeDir( configPath, true, 0700 ) ) &&
|
||||
!cookies->saveToFile( cookiePath ) )
|
||||
Log::error( "Could not save UI HTML cookies to %s", cookiePath.c_str() );
|
||||
};
|
||||
auto cache = WebResourceCache::New();
|
||||
const auto cachePartition = cache->createPartition();
|
||||
BrowserTabs browser;
|
||||
browser.urlBar = urlBar;
|
||||
browser.backBtn = backBtn;
|
||||
browser.fwdBtn = fwdBtn;
|
||||
std::unique_ptr<UITabWidgetSplitter> splitter( UITabWidgetSplitter::New( &browser, ui ) );
|
||||
splitter->setShowTabBarWhenSplit( true );
|
||||
browser.splitter = splitter.get();
|
||||
splitter->setOnTabWidgetCreateCb( [&browser, &splitter]( UITabWidget* tabWidget ) {
|
||||
tabWidget->on( Event::OnTabClosed, [&browser, &splitter]( const Event* ) {
|
||||
if ( !browser.current() )
|
||||
splitter->setCurrentWidget( splitter->getSomeWidget() );
|
||||
if ( !browser.current() ) {
|
||||
browser.urlBar->setText( "" );
|
||||
browser.updateNavigation();
|
||||
}
|
||||
} );
|
||||
} );
|
||||
splitter->createTabWidget( vbox->find( "tabs_host" ) );
|
||||
|
||||
std::function<void( UIWebView* )> configureNavigation;
|
||||
auto newTab = [&]( bool focus = true ) {
|
||||
auto* view = UIWebView::New();
|
||||
view->setStyleSheetDefaultMarker( app.getStyleSheetDefaultMarker() );
|
||||
view->setCookieManager( cookies );
|
||||
view->setWebResourceCache( cache, cachePartition );
|
||||
view->setLayoutSizePolicy( SizePolicy::MatchParent, SizePolicy::MatchParent );
|
||||
auto* target = browser.current() ? splitter->tabWidgetFromWidget( browser.current() )
|
||||
: splitter->getFirstTabWidget();
|
||||
splitter->createWidgetInTabWidget( target, view, "New Tab", focus );
|
||||
view->onNavigationStarted( [&browser, view]( const URI& uri ) {
|
||||
if ( browser.current() == view )
|
||||
browser.urlBar->setText( uri.toString() );
|
||||
} );
|
||||
view->on( Event::OnCreateContextMenu, [view, cookies, cache, ui,
|
||||
&saveCookies]( const Event* event ) {
|
||||
auto* context = static_cast<const ContextMenuEvent*>( event );
|
||||
auto* menu = context->getMenu();
|
||||
std::string linkHref;
|
||||
for ( const Node* node = context->getTarget(); node; node = node->getParent() ) {
|
||||
if ( node->isType( UI_TYPE_TEXTSPAN ) ) {
|
||||
auto* span = node->asConstType<UITextSpan>();
|
||||
if ( span->getElementTag() == "a" ) {
|
||||
auto* link = static_cast<const UIAnchorSpan*>( span );
|
||||
if ( !link->getHref().empty() )
|
||||
linkHref = view->getDocumentSceneNode()
|
||||
->solveRelativePath( URI( link->getHref() ) )
|
||||
.toString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ( node == view->getDocumentContainer() )
|
||||
break;
|
||||
}
|
||||
if ( !linkHref.empty() )
|
||||
menu->add( "Open Link in New Tab" )->setId( "open-link-new-tab" );
|
||||
menu->addSeparator();
|
||||
auto* clearDomain = menu->add( "Clear Domain Cookies" );
|
||||
clearDomain->setId( "clear-domain-cookies" );
|
||||
clearDomain->setEnabled( !view->getCurrentURI().getAuthority().empty() );
|
||||
menu->add( "Clear All Cookies" )->setId( "clear-all-cookies" );
|
||||
menu->add( "Inspect" )->setId( "inspect" );
|
||||
menu->on( Event::OnItemClicked,
|
||||
[view, cookies, cache, ui, &saveCookies,
|
||||
linkHref = std::move( linkHref )]( const Event* itemEvent ) {
|
||||
const auto& id = itemEvent->getNode()->getId();
|
||||
if ( id == "open-link-new-tab" ) {
|
||||
NavigationRequest request{ URI( linkHref ) };
|
||||
request.target = NavigationRequest::Target::NewTab;
|
||||
view->getDocumentSceneNode()->navigate( request );
|
||||
} else if ( id == "clear-domain-cookies" ) {
|
||||
cookies->clearDomain( view->getCurrentURI().getAuthority() );
|
||||
cache->clear();
|
||||
saveCookies();
|
||||
view->refresh();
|
||||
} else if ( id == "clear-all-cookies" ) {
|
||||
cookies->clear();
|
||||
cache->clear();
|
||||
saveCookies();
|
||||
view->refresh();
|
||||
} else if ( id == "inspect" ) {
|
||||
UIWidgetInspector::create( ui );
|
||||
}
|
||||
} );
|
||||
} );
|
||||
configureNavigation( view );
|
||||
return view;
|
||||
};
|
||||
|
||||
webView->onNavigationStarted(
|
||||
[urlBar]( const URI& uri ) { urlBar->setText( uri.toString() ); } );
|
||||
webView->onNavigationCompleted(
|
||||
[webView, updateNavButtons, urlBar, useHNDark]( const URI& uri ) {
|
||||
updateNavButtons();
|
||||
urlBar->setText( uri.toString() );
|
||||
configureNavigation = [useHNDark, &browser, &splitter, &newTab]( UIWebView* webView ) {
|
||||
webView->on( Event::OnLinkOpenRequested, [&newTab]( const Event* event ) {
|
||||
auto* request = static_cast<const UIWebView::LinkOpenEvent*>( event );
|
||||
auto* view = newTab( false );
|
||||
view->loadURI( request->uri );
|
||||
request->accept();
|
||||
} );
|
||||
webView->on( Event::OnFaviconChanged, [webView, &splitter]( const Event* event ) {
|
||||
if ( auto* tab = splitter->getTabFromWidget( webView ) ) {
|
||||
const auto& icon = static_cast<const UIWebView::FaviconEvent*>( event )->icon;
|
||||
tab->setIcon( icon ? TextureDrawable::New( icon ) : DrawablePtr{} );
|
||||
if ( icon )
|
||||
tab->getIcon()->setSize( 16, 16 );
|
||||
}
|
||||
} );
|
||||
webView->on( Event::OnTitleChanged, [webView, &splitter]( const Event* event ) {
|
||||
const auto& title = static_cast<const UIWebView::TitleEvent*>( event )->title;
|
||||
if ( auto* tab = splitter->getTabFromWidget( webView ) ) {
|
||||
const URI& uri = webView->getCurrentURI();
|
||||
tab->setText( title.empty() ? ( uri.getAuthority().empty() ? uri.toString()
|
||||
: uri.getAuthority() )
|
||||
: title );
|
||||
}
|
||||
} );
|
||||
webView->onNavigationCompleted( [webView, &browser, &splitter,
|
||||
useHNDark]( const URI& uri ) {
|
||||
if ( auto* tab = splitter->getTabFromWidget( webView ) )
|
||||
tab->setText( uri.getAuthority().empty() ? uri.toString() : uri.getAuthority() );
|
||||
if ( browser.current() == webView ) {
|
||||
browser.updateNavigation();
|
||||
browser.urlBar->setText( uri.toString() );
|
||||
}
|
||||
|
||||
if ( useHNDark && uri.getAuthority() == "news.ycombinator.com" ) {
|
||||
static const std::string_view HN_DARK = R"css(
|
||||
@@ -192,35 +347,50 @@ EE_MAIN_FUNC int main( int argc, char** argv ) {
|
||||
webView->getDocumentSceneNode()->combineStyleSheet( parser.getStyleSheet() );
|
||||
}
|
||||
} );
|
||||
};
|
||||
|
||||
backBtn->onClick( [webView, updateNavButtons]( const MouseEvent* ) {
|
||||
webView->goHistoryBack();
|
||||
updateNavButtons();
|
||||
backBtn->onClick( [&browser]( const MouseEvent* ) {
|
||||
if ( auto* view = browser.current() ) {
|
||||
view->goHistoryBack();
|
||||
browser.updateNavigation();
|
||||
}
|
||||
} );
|
||||
|
||||
fwdBtn->onClick( [webView, updateNavButtons]( const MouseEvent* ) {
|
||||
webView->goHistoryForward();
|
||||
updateNavButtons();
|
||||
fwdBtn->onClick( [&browser]( const MouseEvent* ) {
|
||||
if ( auto* view = browser.current() ) {
|
||||
view->goHistoryForward();
|
||||
browser.updateNavigation();
|
||||
}
|
||||
} );
|
||||
|
||||
refreshBtn->onClick( [webView]( const MouseEvent* ) { webView->refresh(); } );
|
||||
refreshBtn->onClick( [&browser]( const MouseEvent* ) {
|
||||
if ( auto* view = browser.current() )
|
||||
view->refresh();
|
||||
} );
|
||||
|
||||
updateNavButtons();
|
||||
newTabBtn->onClick( [&newTab]( const MouseEvent* ) { newTab(); } );
|
||||
|
||||
urlBar->on( Event::OnPressEnter,
|
||||
[webView, urlBar]( auto ) { webView->loadURI( urlBar->getText().toUtf8() ); } );
|
||||
urlBar->on( Event::OnPressEnter, [&browser, urlBar]( auto ) {
|
||||
if ( auto* view = browser.current() )
|
||||
view->loadURI( urlBar->getText().toUtf8() );
|
||||
} );
|
||||
|
||||
webView->loadURI( !url.Get().empty() ? url.Get() : "https://news.ycombinator.com" );
|
||||
auto* firstView = newTab();
|
||||
firstView->loadURI( !url.Get().empty() ? url.Get() : "https://news.ycombinator.com" );
|
||||
browser.updateNavigation();
|
||||
|
||||
win->getInput()->pushCallback( [webView]( InputEvent* event ) {
|
||||
win->getInput()->pushCallback( [&browser]( InputEvent* event ) {
|
||||
UIWebView* view = browser.current();
|
||||
if ( !view )
|
||||
return;
|
||||
switch ( event->Type ) {
|
||||
case InputEvent::FileDropped: {
|
||||
std::string file( event->file.file );
|
||||
webView->loadURI( "file://" + file );
|
||||
view->loadURI( "file://" + file );
|
||||
break;
|
||||
}
|
||||
case InputEvent::TextDropped: {
|
||||
webView->loadURI( event->textdrop.text );
|
||||
view->loadURI( event->textdrop.text );
|
||||
break;
|
||||
}
|
||||
default:
|
||||
@@ -247,7 +417,10 @@ EE_MAIN_FUNC int main( int argc, char** argv ) {
|
||||
TextHints::AllAscii );
|
||||
app.getWindow()->display();
|
||||
} );
|
||||
saveCookies();
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
return app.run();
|
||||
int result = app.run();
|
||||
saveCookies();
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#include "utest.h"
|
||||
|
||||
#include <eepp/network/cookiemanager.hpp>
|
||||
#include <eepp/system/filesystem.hpp>
|
||||
#include <eepp/system/sys.hpp>
|
||||
|
||||
using namespace EE;
|
||||
using namespace EE::Network;
|
||||
@@ -74,3 +76,30 @@ UTEST( CookieManager, storeFromRawHeader ) {
|
||||
cm.storeCookiesFromHeader( "example.com", "key=value; Path=/" );
|
||||
EXPECT_TRUE( cm.getCookieHeader( "example.com" ) == "key=value" );
|
||||
}
|
||||
|
||||
UTEST( CookieManager, clearDomain ) {
|
||||
CookieManager cm;
|
||||
cm.storeCookiesFromHeader( "one.example", "a=1" );
|
||||
cm.storeCookiesFromHeader( "two.example", "b=2" );
|
||||
cm.clearDomain( "one.example" );
|
||||
EXPECT_FALSE( cm.hasCookie( "one.example" ) );
|
||||
EXPECT_TRUE( cm.hasCookie( "two.example" ) );
|
||||
}
|
||||
|
||||
UTEST( CookieManager, persistence ) {
|
||||
const std::string path = Sys::getTempPath() + "eepp_cookies_test.txt";
|
||||
CookieManager saved;
|
||||
saved.storeCookiesFromHeader( "one.example", "name=value with spaces" );
|
||||
saved.storeCookiesFromHeader( "two.example", "other=\"quoted\"" );
|
||||
ASSERT_TRUE( saved.saveToFile( path ) );
|
||||
CookieManager loaded;
|
||||
ASSERT_TRUE( loaded.loadFromFile( path ) );
|
||||
EXPECT_TRUE( loaded.getCookieHeader( "one.example" ) == "name=value with spaces" );
|
||||
EXPECT_TRUE( loaded.getCookieHeader( "two.example" ) == "other=\"quoted\"" );
|
||||
saved.storeCookiesFromHeader( "one.example", "name=replaced" );
|
||||
ASSERT_TRUE( saved.saveToFile( path ) );
|
||||
ASSERT_TRUE( loaded.loadFromFile( path ) );
|
||||
EXPECT_TRUE( loaded.getCookieHeader( "one.example" ) == "name=replaced" );
|
||||
EXPECT_FALSE( FileSystem::fileExists( path + ".tmp" ) );
|
||||
FileSystem::fileRemove( path );
|
||||
}
|
||||
|
||||
@@ -115,6 +115,51 @@ UTEST( Http, responseHeaderLineLargerThanReceiveBuffer ) {
|
||||
EXPECT_TRUE( response.getBody() == "hello" );
|
||||
}
|
||||
|
||||
UTEST( Http, redirectSendsCookiePairWithoutSetCookieAttributes ) {
|
||||
TcpListener listener;
|
||||
ASSERT_EQ( listener.listen( Socket::AnyPort, IpAddress::LocalHost ), Socket::Done );
|
||||
std::string redirectedRequest;
|
||||
std::atomic<bool> serverOk{ false };
|
||||
std::thread server( [&] {
|
||||
for ( int step = 0; step < 2; ++step ) {
|
||||
TcpSocket client;
|
||||
if ( listener.accept( client ) != Socket::Done )
|
||||
return;
|
||||
std::string request;
|
||||
char buffer[1024];
|
||||
std::size_t received = 0;
|
||||
while ( request.find( "\r\n\r\n" ) == std::string::npos ) {
|
||||
if ( client.receive( buffer, sizeof( buffer ), received ) != Socket::Done )
|
||||
return;
|
||||
request.append( buffer, received );
|
||||
}
|
||||
if ( step == 1 )
|
||||
redirectedRequest = std::move( request );
|
||||
const std::string response = step == 0
|
||||
? "HTTP/1.1 303 See Other\r\nLocation: /landing\r\n"
|
||||
"Set-Cookie: session=new; Path=/; HttpOnly\r\n"
|
||||
"Content-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
: "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n"
|
||||
"Connection: close\r\n\r\nok";
|
||||
if ( client.send( response.data(), response.size() ) != Socket::Done )
|
||||
return;
|
||||
client.disconnect();
|
||||
}
|
||||
serverOk = true;
|
||||
} );
|
||||
Http http( "127.0.0.1", listener.getLocalPort() );
|
||||
Http::Request request( "/login" );
|
||||
request.setField( "Cookie", "session=old; pref=1" );
|
||||
Http::Response response = http.sendRequest( request, Seconds( 5 ) );
|
||||
server.join();
|
||||
listener.close();
|
||||
EXPECT_TRUE( serverOk );
|
||||
EXPECT_EQ( response.getStatus(), Http::Response::Ok );
|
||||
EXPECT_TRUE( redirectedRequest.find( "GET /landing " ) == 0 );
|
||||
EXPECT_TRUE( redirectedRequest.find( "cookie: session=new; pref=1\r\n" ) != std::string::npos );
|
||||
EXPECT_TRUE( redirectedRequest.find( "HttpOnly" ) == std::string::npos );
|
||||
}
|
||||
|
||||
UTEST( Http, failedTlsHandshakesReleaseConnectionState ) {
|
||||
if ( !SSL::SSLSocket::isSupported() )
|
||||
return;
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
#include <eepp/graphics/image.hpp>
|
||||
#include <eepp/graphics/renderer/renderer.hpp>
|
||||
#include <eepp/graphics/resourcescope.hpp>
|
||||
#include <eepp/graphics/text.hpp>
|
||||
#include <eepp/graphics/texturedrawable.hpp>
|
||||
#include <eepp/graphics/texturefactory.hpp>
|
||||
#include <eepp/graphics/textureregion.hpp>
|
||||
#include <eepp/scene/eventdispatcher.hpp>
|
||||
#include <eepp/scene/keyevent.hpp>
|
||||
#include <eepp/scene/scenemanager.hpp>
|
||||
#include <eepp/system/filesystem.hpp>
|
||||
@@ -3622,6 +3624,78 @@ UTEST( UIHTML, WebViewContextMenuCopiesLinkUsingDocumentURI ) {
|
||||
Engine::destroySingleton();
|
||||
}
|
||||
|
||||
UTEST( UIHTML, WebViewOpensResolvedLinkInNewTab ) {
|
||||
init_ui_test();
|
||||
auto* scene = SceneManager::instance()->getUISceneNode();
|
||||
auto* webView = UIWebView::New();
|
||||
webView->setParent( scene->getRoot() );
|
||||
auto* document = webView->getDocumentSceneNode();
|
||||
document->setURIFromURL( URI( "https://docs.example.com/guide/index.html" ) );
|
||||
document->loadLayoutFromString(
|
||||
HTMLFormatter::HTMLtoXML(
|
||||
R"html(<html><body><a id="link" href="../api/item">link</a></body></html>)html" ),
|
||||
webView->getDocumentContainer() );
|
||||
webView->getTextSelectionController()->onDocumentChanged();
|
||||
scene->update( Seconds( 1 ) );
|
||||
auto* anchor = document->find<UIAnchorSpan>( "link" );
|
||||
ASSERT_TRUE( anchor != nullptr );
|
||||
std::vector<std::string> opened;
|
||||
webView->on( Event::OnLinkOpenRequested, [&]( const Event* event ) {
|
||||
auto* request = static_cast<const UIWebView::LinkOpenEvent*>( event );
|
||||
opened.push_back( request->uri.toString() );
|
||||
request->accept();
|
||||
} );
|
||||
NodeMessage middleClick( anchor, NodeMessage::MouseClick, EE_BUTTON_MMASK );
|
||||
anchor->messagePost( &middleClick );
|
||||
ASSERT_EQ( opened.size(), 1u );
|
||||
EXPECT_STDSTREQ( opened.back(), "https://docs.example.com/api/item" );
|
||||
NavigationRequest modifiedClick{ URI( anchor->getHref() ) };
|
||||
modifiedClick.source = anchor;
|
||||
modifiedClick.mouseButtons = EE_BUTTON_LMASK;
|
||||
modifiedClick.modifiers = KeyMod::getDefaultModifier();
|
||||
document->navigate( modifiedClick );
|
||||
ASSERT_EQ( opened.size(), 2u );
|
||||
EXPECT_STDSTREQ( opened.back(), "https://docs.example.com/api/item" );
|
||||
UIPopUpMenu* menu = nullptr;
|
||||
webView->on( Event::OnCreateContextMenu, [&]( const Event* event ) {
|
||||
auto* context = static_cast<const ContextMenuEvent*>( event );
|
||||
menu = context->getMenu();
|
||||
if ( context->getTarget() == anchor ) {
|
||||
menu->add( "Open Link in New Tab" )->setId( "open-link-new-tab" );
|
||||
menu->on( Event::OnItemClicked, [document, anchor]( const Event* itemEvent ) {
|
||||
if ( itemEvent->getNode()->getId() == "open-link-new-tab" ) {
|
||||
NavigationRequest request{ URI( anchor->getHref() ) };
|
||||
request.target = NavigationRequest::Target::NewTab;
|
||||
document->navigate( request );
|
||||
}
|
||||
} );
|
||||
}
|
||||
} );
|
||||
NodeMessage rightUp( anchor, NodeMessage::MouseUp, EE_BUTTON_RMASK );
|
||||
anchor->messagePost( &rightUp );
|
||||
ASSERT_TRUE( menu != nullptr );
|
||||
auto* open = menu->getItemId( "open-link-new-tab" );
|
||||
ASSERT_TRUE( open != nullptr );
|
||||
open->activate();
|
||||
ASSERT_EQ( opened.size(), 3u );
|
||||
EXPECT_STDSTREQ( opened.back(), "https://docs.example.com/api/item" );
|
||||
Engine::destroySingleton();
|
||||
}
|
||||
|
||||
UTEST( UIHTML, PasswordTextUsesUpdatedFontColor ) {
|
||||
struct PasswordInputProbe : UITextInput {
|
||||
PasswordInputProbe() : UITextInput() {}
|
||||
Color visibleColor() { return getVisibleTextCache().getFillColor(); }
|
||||
};
|
||||
init_ui_test();
|
||||
auto* input = eeNew( PasswordInputProbe, () );
|
||||
input->setParent( SceneManager::instance()->getUISceneNode()->getRoot() );
|
||||
input->setMode( UITextInput::TextInputMode::Password );
|
||||
input->setFontColor( Color( 17, 31, 47 ) );
|
||||
EXPECT_TRUE( input->visibleColor() == Color( 17, 31, 47 ) );
|
||||
Engine::destroySingleton();
|
||||
}
|
||||
|
||||
UTEST( UIHTML, MarkdownContextMenuOmitsLinkForSelection ) {
|
||||
init_ui_test();
|
||||
auto* sceneNode = SceneManager::instance()->getUISceneNode();
|
||||
@@ -7141,3 +7215,65 @@ UTEST( UIHTML, ImageHeightAutoMaxWidthStyleStateTransition ) {
|
||||
|
||||
Engine::destroySingleton();
|
||||
}
|
||||
|
||||
UTEST( UIHTML, MarkdownSpaceScrollingUsesParentViewport ) {
|
||||
init_ui_test();
|
||||
auto* scene = SceneManager::instance()->getUISceneNode();
|
||||
auto* scroll = UIScrollView::New();
|
||||
scroll->setParent( scene->getRoot() );
|
||||
scroll->setPixelsSize( 400, 300 );
|
||||
scroll->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed );
|
||||
auto* markdown = UIMarkdownView::New();
|
||||
markdown->setParent( scroll->getContainer() );
|
||||
std::string text;
|
||||
for ( int i = 0; i < 60; ++i )
|
||||
text += "A paragraph of Markdown content.\n\n";
|
||||
markdown->loadFromString( text );
|
||||
for ( int i = 0; i < 10; ++i )
|
||||
scene->update( Seconds( 1.f / 60.f ) );
|
||||
auto* bar = scroll->getVerticalScrollBar();
|
||||
const Float viewport = scroll->getContainer()->getPixelsSize().getHeight();
|
||||
const Float range = markdown->getPixelsSize().getHeight() - viewport;
|
||||
ASSERT_TRUE( range > 2.f * viewport );
|
||||
scene->getEventDispatcher()->setFocusNode( markdown );
|
||||
scene->getEventDispatcher()->sendKeyDown( KEY_SPACE, SCANCODE_SPACE, 0, 0 );
|
||||
EXPECT_NEAR( viewport, bar->getValue() * range, 1.f );
|
||||
scene->getEventDispatcher()->sendKeyDown( KEY_SPACE, SCANCODE_SPACE, 0, KEYMOD_LSHIFT );
|
||||
EXPECT_EQ( 0.f, bar->getValue() );
|
||||
scene->getWindow()->startTextInput();
|
||||
scene->getEventDispatcher()->sendKeyDown( KEY_SPACE, SCANCODE_SPACE, 0, 0 );
|
||||
EXPECT_EQ( 0.f, bar->getValue() );
|
||||
scene->getEventDispatcher()->sendTextInput( ' ', 0 );
|
||||
EXPECT_NEAR( viewport, bar->getValue() * range, 1.f );
|
||||
scroll->setEnableDefaultKeybindings( false );
|
||||
scene->getEventDispatcher()->sendTextInput( ' ', 0 );
|
||||
EXPECT_NEAR( viewport, bar->getValue() * range, 1.f );
|
||||
scene->getWindow()->stopTextInput();
|
||||
scene->getEventDispatcher()->sendKeyDown( KEY_SPACE, SCANCODE_SPACE, 0, 0 );
|
||||
EXPECT_NEAR( viewport, bar->getValue() * range, 1.f );
|
||||
Engine::destroySingleton();
|
||||
}
|
||||
|
||||
UTEST( UIHTML, PasswordSelectionUsesVisibleGlyphWidth ) {
|
||||
struct PasswordSelectionProbe : UITextInput {
|
||||
Float maskedWidth() { return getVisibleTextCache().getTextWidth(); }
|
||||
|
||||
Float selectedWidth() {
|
||||
selCurInit( 0 );
|
||||
selCurEnd( getText().size() );
|
||||
drawSelection( getVisibleTextCache() );
|
||||
return mSelRectsCache.empty() ? 0.f : mSelRectsCache.front().getSize().getWidth();
|
||||
}
|
||||
};
|
||||
static_assert( sizeof( PasswordSelectionProbe ) == sizeof( UITextInput ) );
|
||||
init_ui_test();
|
||||
auto* input = eeNew( PasswordSelectionProbe, () );
|
||||
input->setParent( SceneManager::instance()->getUISceneNode()->getRoot() );
|
||||
input->setText( "WWWWWWWW" );
|
||||
const Float unmaskedWidth = input->getTextCache()->getTextWidth();
|
||||
input->setMode( UITextInput::TextInputMode::Password );
|
||||
const Float maskedWidth = input->maskedWidth();
|
||||
ASSERT_TRUE( std::abs( unmaskedWidth - maskedWidth ) > 1.f );
|
||||
EXPECT_NEAR( maskedWidth, input->selectedWidth(), 1.f );
|
||||
Engine::destroySingleton();
|
||||
}
|
||||
|
||||
@@ -130,6 +130,30 @@ UTEST( UIHTMLForm, submitPOST ) {
|
||||
Engine::destroySingleton();
|
||||
}
|
||||
|
||||
UTEST( UIHTMLForm, emptyActionSubmitsToCurrentDocumentURL ) {
|
||||
auto* sceneNode = initFormTest( "Form Empty Action Test" );
|
||||
sceneNode->setURIFromURL( URI( "https://news.ycombinator.com/login?goto=news" ) );
|
||||
NavigationRequest intercepted;
|
||||
sceneNode->setNavigationInterceptorCb( [&]( const NavigationRequest& request ) {
|
||||
intercepted = request;
|
||||
return true;
|
||||
} );
|
||||
auto* form = UIHTMLForm::New();
|
||||
form->setParent( sceneNode->getRoot() );
|
||||
form->setMethod( "post" );
|
||||
auto* account = UIHTMLInput::New();
|
||||
account->setParent( form );
|
||||
account->setInputType( "text" );
|
||||
account->setStyleSheetInlineProperty( "name", "acct" );
|
||||
static_cast<UITextInput*>( account->getChildWidget() )->setText( "example" );
|
||||
form->submit();
|
||||
EXPECT_TRUE( intercepted.uri.toString() == "https://news.ycombinator.com/login?goto=news" );
|
||||
EXPECT_TRUE( intercepted.method == "POST" );
|
||||
EXPECT_TRUE( intercepted.body.find( "acct=example" ) != std::string::npos );
|
||||
EXPECT_TRUE( intercepted.source == form );
|
||||
Engine::destroySingleton();
|
||||
}
|
||||
|
||||
UTEST( UIHTMLForm, getValueCheckbox ) {
|
||||
auto* sceneNode = initFormTest( "Form Checkbox Value" );
|
||||
|
||||
|
||||
@@ -333,6 +333,36 @@ UTEST( UIInspector, InputActionErrorsAndKeyEvents ) {
|
||||
{ "params", { { "selector", "#first" } } } } );
|
||||
ASSERT_TRUE( client.receive().contains( "result" ) );
|
||||
EXPECT_EQ( clicks, 1 );
|
||||
int rightClicks = 0;
|
||||
first->on( Event::MouseUp, [&]( const Event* event ) {
|
||||
if ( event->asMouseEvent()->getFlags() & EE_BUTTON_RMASK )
|
||||
++rightClicks;
|
||||
} );
|
||||
client.send( { { "id", id++ },
|
||||
{ "method", "input.click" },
|
||||
{ "params", { { "selector", "#first" }, { "button", "right" } } } } );
|
||||
ASSERT_TRUE( client.receive().contains( "result" ) );
|
||||
EXPECT_EQ( rightClicks, 1 );
|
||||
EXPECT_EQ( clicks, 1 );
|
||||
int middleClicks = 0;
|
||||
first->on( Event::MouseClick, [&]( const Event* event ) {
|
||||
if ( event->asMouseEvent()->getFlags() & EE_BUTTON_MMASK )
|
||||
++middleClicks;
|
||||
} );
|
||||
scene->getEventDispatcher()->setFocusNode( second );
|
||||
client.send( { { "id", id++ },
|
||||
{ "method", "input.click" },
|
||||
{ "params", { { "selector", "#first" }, { "button", "middle" } } } } );
|
||||
ASSERT_TRUE( client.receive().contains( "result" ) );
|
||||
EXPECT_EQ( middleClicks, 1 );
|
||||
EXPECT_EQ( clicks, 1 );
|
||||
EXPECT_TRUE( scene->getEventDispatcher()->getFocusNode() == second );
|
||||
client.send( { { "id", id++ },
|
||||
{ "method", "input.click" },
|
||||
{ "params", { { "selector", "#first" }, { "button", "other" } } } } );
|
||||
json invalidButton = client.receive();
|
||||
ASSERT_TRUE( invalidButton.contains( "error" ) );
|
||||
EXPECT_TRUE( invalidButton["error"]["code"] == "invalid-params" );
|
||||
|
||||
scene->getEventDispatcher()->setFocusNode( first );
|
||||
int keyDown = 0;
|
||||
|
||||
@@ -891,3 +891,27 @@ UTEST( UIWindow, ModalWindowStopsKeyBindingsFromReachingScene ) {
|
||||
|
||||
Engine::destroySingleton();
|
||||
}
|
||||
|
||||
UTEST( UISceneNode, CookieJarIsPrivateByDefaultAndReplaceable ) {
|
||||
UIApplication app(
|
||||
WindowSettings{ 320, 240, "Shared cookie jars" },
|
||||
UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1.f ) );
|
||||
auto* first = app.getUI();
|
||||
auto* second = UISceneNode::New();
|
||||
ASSERT_TRUE( &first->getCookieManager() != &second->getCookieManager() );
|
||||
first->getCookieManager().storeCookiesFromHeader( "example.com", "private=value" );
|
||||
EXPECT_TRUE( second->getCookieManager().getCookieHeader( "example.com" ).empty() );
|
||||
|
||||
auto shared = std::make_shared<CookieManager>();
|
||||
first->setCookieManager( shared );
|
||||
second->setCookieManager( shared );
|
||||
EXPECT_TRUE( &first->getCookieManager() == shared.get() );
|
||||
EXPECT_TRUE( &second->getCookieManager() == shared.get() );
|
||||
first->getCookieManager().storeCookiesFromHeader( "example.com", "shared=value" );
|
||||
EXPECT_TRUE( second->getCookieManager().getCookieHeader( "example.com" ) == "shared=value" );
|
||||
first->setCookieManager( nullptr );
|
||||
EXPECT_TRUE( &first->getCookieManager() != shared.get() );
|
||||
EXPECT_TRUE( first->getCookieManager().getCookieHeader( "example.com" ).empty() );
|
||||
EXPECT_TRUE( second->getCookieManager().getCookieHeader( "example.com" ) == "shared=value" );
|
||||
eeDelete( second );
|
||||
}
|
||||
|
||||
@@ -648,6 +648,50 @@ UTEST( UITabWidgetSplitter, ClosingSplitNotifiesBeforeTabWidgetDestruction ) {
|
||||
eeDelete( splitter );
|
||||
}
|
||||
|
||||
UTEST( UITabWidgetSplitter, ShowsEveryTabBarWhileSplitWhenEnabled ) {
|
||||
UIApplication app(
|
||||
WindowSettings( 800, 600, "eepp - unit tests" ),
|
||||
UIApplication::Settings( Sys::getProcessPath() + ".." + FileSystem::getOSSlash(), 1 ) );
|
||||
TestClient client;
|
||||
auto* splitter = UITabWidgetSplitter::New( &client, app.getUI() );
|
||||
auto* container = UILayout::New();
|
||||
container->setParent( app.getUI() );
|
||||
auto* firstTabs = splitter->createTabWidget( container );
|
||||
auto* firstWidget = UIWidget::New();
|
||||
splitter->createWidgetInTabWidget( firstTabs, firstWidget, "First" );
|
||||
EXPECT_FALSE( firstTabs->getTabBar()->isVisible() );
|
||||
|
||||
auto* secondTabs = splitter->splitTabWidget( SplitDirection::Right, firstTabs );
|
||||
ASSERT_TRUE( secondTabs != nullptr );
|
||||
EXPECT_FALSE( splitter->getShowTabBarWhenSplit() );
|
||||
EXPECT_FALSE( firstTabs->getTabBar()->isVisible() );
|
||||
EXPECT_FALSE( secondTabs->getTabBar()->isVisible() );
|
||||
splitter->setShowTabBarWhenSplit( true );
|
||||
EXPECT_TRUE( splitter->getShowTabBarWhenSplit() );
|
||||
EXPECT_TRUE( firstTabs->getTabBar()->isVisible() );
|
||||
EXPECT_TRUE( secondTabs->getTabBar()->isVisible() );
|
||||
splitter->setShowTabBarWhenSplit( false );
|
||||
EXPECT_FALSE( firstTabs->getTabBar()->isVisible() );
|
||||
EXPECT_FALSE( secondTabs->getTabBar()->isVisible() );
|
||||
splitter->setShowTabBarWhenSplit( true );
|
||||
splitter->setHideTabBar( true );
|
||||
EXPECT_FALSE( firstTabs->getTabBar()->isVisible() );
|
||||
EXPECT_FALSE( secondTabs->getTabBar()->isVisible() );
|
||||
splitter->setHideTabBar( false );
|
||||
EXPECT_TRUE( firstTabs->getTabBar()->isVisible() );
|
||||
EXPECT_TRUE( secondTabs->getTabBar()->isVisible() );
|
||||
auto* secondWidget = UIWidget::New();
|
||||
splitter->createWidgetInTabWidget( secondTabs, secondWidget, "Second" );
|
||||
splitter->closeTab( secondWidget, UITabWidget::FocusTabBehavior::Default );
|
||||
EXPECT_EQ( splitter->getTabWidgets().size(), 1UL );
|
||||
EXPECT_FALSE( firstTabs->getTabBar()->isVisible() );
|
||||
auto* newSplit = splitter->splitTabWidget( SplitDirection::Bottom, firstTabs );
|
||||
ASSERT_TRUE( newSplit != nullptr );
|
||||
EXPECT_TRUE( firstTabs->getTabBar()->isVisible() );
|
||||
EXPECT_TRUE( newSplit->getTabBar()->isVisible() );
|
||||
eeDelete( splitter );
|
||||
}
|
||||
|
||||
UTEST( UITabWidgetSplitter, Serialization ) {
|
||||
UIApplication app(
|
||||
WindowSettings( 800, 600, "eepp - unit tests" ),
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
#include <eepp/network/http.hpp>
|
||||
#include <eepp/network/tcplistener.hpp>
|
||||
#include <eepp/network/tcpsocket.hpp>
|
||||
#include <eepp/scene/eventdispatcher.hpp>
|
||||
#include <eepp/scene/scenemanager.hpp>
|
||||
#include <eepp/system/clock.hpp>
|
||||
#include <eepp/system/filesystem.hpp>
|
||||
@@ -32,6 +33,8 @@
|
||||
#include <eepp/ui/uiroot.hpp>
|
||||
#include <eepp/ui/uiscenenode.hpp>
|
||||
#include <eepp/ui/uiscrollbar.hpp>
|
||||
#include <eepp/ui/uitextedit.hpp>
|
||||
#include <eepp/ui/uitextinput.hpp>
|
||||
#include <eepp/ui/uitextspan.hpp>
|
||||
#include <eepp/ui/uithememanager.hpp>
|
||||
#include <eepp/ui/uiwebview.hpp>
|
||||
@@ -54,6 +57,36 @@ class CountingDrawWidget : public UIWidget {
|
||||
int drawCount{ 0 };
|
||||
};
|
||||
|
||||
// A custom text consumer with no editor type identity or key-down handler.
|
||||
class TextConsumerWidget : public UIWidget {
|
||||
public:
|
||||
TextConsumerWidget() : UIWidget( "custom-text-consumer" ) {}
|
||||
|
||||
Uint32 onFocus( NodeFocusReason reason ) override {
|
||||
UIWidget::onFocus( reason );
|
||||
getUISceneNode()->getWindow()->startTextInput();
|
||||
return 1;
|
||||
}
|
||||
|
||||
Uint32 onFocusLoss() override {
|
||||
getUISceneNode()->getWindow()->stopTextInput();
|
||||
return UIWidget::onFocusLoss();
|
||||
}
|
||||
|
||||
Uint32 onTextInput( const TextInputEvent& ) override {
|
||||
if ( consumesText ) {
|
||||
++acceptedText;
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
Uint32 acceptedText{ 0 };
|
||||
bool consumesText{ true };
|
||||
};
|
||||
|
||||
static_assert( sizeof( TextConsumerWidget ) <= sizeof( UIWidget ) + 8 );
|
||||
|
||||
static bool readHttpRequestHeaders( TcpSocket& client, std::string* headers = nullptr ) {
|
||||
std::string request;
|
||||
char buffer[1024];
|
||||
@@ -69,6 +102,36 @@ static bool readHttpRequestHeaders( TcpSocket& client, std::string* headers = nu
|
||||
return true;
|
||||
}
|
||||
|
||||
UTEST( UIWebView, EmitsParsedDocumentTitle ) {
|
||||
auto* window = Engine::instance()->createWindow(
|
||||
WindowSettings( 320, 240, "UIWebView Title Test", WindowStyle::Default,
|
||||
WindowBackend::Default, 32, {}, 1, false, true ),
|
||||
ContextSettings( false, 0, 0, GLv_default, true, false ) );
|
||||
FileSystem::changeWorkingDirectory( Sys::getProcessPath() );
|
||||
auto* font = FontTrueType::New( "NotoSans-Regular" ).get();
|
||||
font->loadFromFile( "../assets/fonts/NotoSans-Regular.ttf" );
|
||||
FontFamily::loadFromRegular( font );
|
||||
auto* scene = UISceneNode::New();
|
||||
SceneManager::instance()->add( scene );
|
||||
scene->getUIThemeManager()->setDefaultFont( font );
|
||||
auto* view = UIWebView::New();
|
||||
view->setParent( scene->getRoot() );
|
||||
std::string title;
|
||||
view->onTitleChanged( [&]( const std::string& value ) { title = value; } );
|
||||
const std::string path = Sys::getTempPath() + "eepp_uiwebview_title.html";
|
||||
FileSystem::fileWrite(
|
||||
path, "<html><head><title>Useful & Clear</title></head><body>text</body></html>" );
|
||||
view->loadURI( URI( "file://" + path ) );
|
||||
for ( int i = 0; i < 10 && title.empty(); ++i ) {
|
||||
window->getInput()->update();
|
||||
SceneManager::instance()->update( Seconds( 1.f / 60.f ) );
|
||||
}
|
||||
EXPECT_TRUE( title == "Useful & Clear" );
|
||||
EXPECT_TRUE( view->getTitle() == title );
|
||||
FileSystem::fileRemove( path );
|
||||
Engine::destroySingleton();
|
||||
}
|
||||
|
||||
UTEST( UIWebView, DocumentSceneInheritsHostFontRenderingPolicy ) {
|
||||
auto win = Engine::instance()->createWindow(
|
||||
WindowSettings( 320, 240, "UIWebView Font Policy Test", WindowStyle::Default,
|
||||
@@ -3436,3 +3499,222 @@ UTEST( UIWebView, RepeatedRemoteNavigationHandlesSubresourceFanOut ) {
|
||||
|
||||
Engine::destroySingleton();
|
||||
}
|
||||
|
||||
UTEST( UIWebView, FaviconUsesRelativeURLAndIgnoresStaleCompletion ) {
|
||||
auto win = Engine::instance()->createWindow(
|
||||
WindowSettings( 800, 600, "UIWebView Cache Lease Boundary Test", WindowStyle::Default,
|
||||
WindowBackend::Default, 32, {}, 1, false, true ),
|
||||
ContextSettings( false, 0, 0, GLv_default, true, false ) );
|
||||
FileSystem::changeWorkingDirectory( Sys::getProcessPath() );
|
||||
|
||||
FontTrueType* font = FontTrueType::New( "NotoSans-Regular" ).get();
|
||||
font->loadFromFile( "../assets/fonts/NotoSans-Regular.ttf" );
|
||||
ASSERT_TRUE( font != nullptr && font->loaded() );
|
||||
FontFamily::loadFromRegular( font );
|
||||
|
||||
UISceneNode* sceneNode = UISceneNode::New();
|
||||
SceneManager::instance()->add( sceneNode );
|
||||
sceneNode->getUIThemeManager()->setDefaultFont( font );
|
||||
|
||||
UIWebView* webView = UIWebView::New();
|
||||
webView->setParent( sceneNode->getRoot() );
|
||||
webView->setPixelsSize( 300, 200 );
|
||||
webView->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed );
|
||||
|
||||
auto cache = WebResourceCache::New();
|
||||
std::vector<WebResourceCache::FetchCompletion> icons;
|
||||
std::string requestedIcon;
|
||||
cache->setFetcher(
|
||||
[&]( const WebResourceRequest& request, WebResourceCache::FetchCompletion completion ) {
|
||||
if ( request.kind == WebResourceKind::Image ) {
|
||||
requestedIcon = request.uri.toString();
|
||||
icons.emplace_back( std::move( completion ) );
|
||||
} else {
|
||||
auto status = Http::Response::Status::Ok;
|
||||
Http::Response::FieldTable fields;
|
||||
completion( Http::Response::createFakeResponse(
|
||||
fields, status,
|
||||
request.uri.getPath() == "/blank"
|
||||
? "<html><body>blank</body></html>"
|
||||
: "<html><head><link rel='SHORTCUT icon' "
|
||||
"href='y18.svg'></head><body>icon</body></html>" ) );
|
||||
}
|
||||
} );
|
||||
webView->setWebResourceCache( cache );
|
||||
int loadedIcons = 0;
|
||||
int clearedIcons = 0;
|
||||
webView->on( Event::OnFaviconChanged, [&]( const Event* event ) {
|
||||
const auto& icon = static_cast<const UIWebView::FaviconEvent*>( event )->icon;
|
||||
if ( icon ) {
|
||||
++loadedIcons;
|
||||
EXPECT_EQ( 18u, icon->getImageWidth() );
|
||||
} else {
|
||||
++clearedIcons;
|
||||
}
|
||||
} );
|
||||
auto pump = [&] {
|
||||
for ( int i = 0; i < 10; ++i ) {
|
||||
win->getInput()->update();
|
||||
SceneManager::instance()->update( Seconds( 1.f / 60.f ) );
|
||||
}
|
||||
};
|
||||
auto completeIcon = [&] {
|
||||
ASSERT_FALSE( icons.empty() );
|
||||
auto completion = std::move( icons.front() );
|
||||
icons.erase( icons.begin() );
|
||||
auto status = Http::Response::Status::Ok;
|
||||
Http::Response::FieldTable fields;
|
||||
completion( Http::Response::createFakeResponse(
|
||||
fields, status,
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' width='18' height='18'><rect width='18' "
|
||||
"height='18' fill='orange'/></svg>" ) );
|
||||
pump();
|
||||
};
|
||||
webView->loadURI( URI( "https://favicon.example/sub/page" ) );
|
||||
pump();
|
||||
EXPECT_STREQ( "https://favicon.example/sub/y18.svg", requestedIcon.c_str() );
|
||||
completeIcon();
|
||||
EXPECT_EQ( 1, loadedIcons );
|
||||
webView->loadURI( URI( "https://favicon.example/other/page" ) );
|
||||
pump();
|
||||
webView->loadURI( URI( "https://favicon.example/blank" ) );
|
||||
pump();
|
||||
completeIcon();
|
||||
EXPECT_EQ( 1, loadedIcons );
|
||||
EXPECT_EQ( 3, clearedIcons );
|
||||
cache.reset();
|
||||
Engine::destroySingleton();
|
||||
}
|
||||
|
||||
UTEST( UIWebView, DefaultShortcutsScrollByViewportAndRespectFocusedControls ) {
|
||||
auto* window = Engine::instance()->createWindow(
|
||||
WindowSettings( 400, 300, "UIWebView scrolling shortcuts", WindowStyle::Default,
|
||||
WindowBackend::Default, 32, {}, 1, false, true ),
|
||||
ContextSettings( false, 0, 0, GLv_default, true, false ) );
|
||||
FileSystem::changeWorkingDirectory( Sys::getProcessPath() );
|
||||
auto* font = FontTrueType::New( "NotoSans-Regular" ).get();
|
||||
font->loadFromFile( "../assets/fonts/NotoSans-Regular.ttf" );
|
||||
FontFamily::loadFromRegular( font );
|
||||
auto* scene = UISceneNode::New();
|
||||
SceneManager::instance()->add( scene );
|
||||
scene->getUIThemeManager()->setDefaultFont( font );
|
||||
auto* view = UIWebView::New();
|
||||
view->setParent( scene->getRoot() );
|
||||
view->setPixelsSize( 400, 300 );
|
||||
view->setLayoutSizePolicy( SizePolicy::Fixed, SizePolicy::Fixed );
|
||||
const std::string path = Sys::getTempPath() + "eepp_uiwebview_scroll_keys.html";
|
||||
FileSystem::fileWrite(
|
||||
path, "<html><body style='margin:0'><input id='entry'><details><summary "
|
||||
"id='summary'>Toggle</summary>"
|
||||
"<p>details</p></details><div style='height:3000px'>tall</div></body></html>" );
|
||||
view->loadURI( URI( "file://" + path ) );
|
||||
for ( int i = 0; i < 10; ++i ) {
|
||||
window->getInput()->update();
|
||||
SceneManager::instance()->update( Seconds( 1.f / 60.f ) );
|
||||
}
|
||||
auto* document = view->getDocumentSceneNode();
|
||||
const Float viewport = document->getViewportPixelsSize().getHeight();
|
||||
auto* scroll = view->getVerticalScrollBar();
|
||||
const Float range = document->getPixelsSize().getHeight() - viewport;
|
||||
ASSERT_TRUE( range > viewport * 3.f );
|
||||
auto press = [&]( Keycode key, Uint32 mod = 0 ) {
|
||||
scene->getEventDispatcher()->sendKeyDown( key, SCANCODE_UNKNOWN, 0, mod );
|
||||
};
|
||||
scene->getEventDispatcher()->setFocusNode( view );
|
||||
press( KEY_SPACE );
|
||||
EXPECT_NEAR( viewport, scroll->getValue() * range, 1.f );
|
||||
press( KEY_PAGEDOWN );
|
||||
EXPECT_NEAR( 2.f * viewport, scroll->getValue() * range, 1.f );
|
||||
press( KEY_PAGEUP );
|
||||
EXPECT_NEAR( viewport, scroll->getValue() * range, 1.f );
|
||||
press( KEY_SPACE, KEYMOD_LSHIFT );
|
||||
EXPECT_NEAR( 0.f, scroll->getValue(), 0.001f );
|
||||
press( KEY_PAGEUP );
|
||||
EXPECT_EQ( 0.f, scroll->getValue() );
|
||||
scroll->setValue( 1.f - viewport / ( 2.f * range ) );
|
||||
press( KEY_PAGEDOWN );
|
||||
EXPECT_EQ( 1.f, scroll->getValue() );
|
||||
press( KEY_PAGEUP );
|
||||
EXPECT_NEAR( range - viewport, scroll->getValue() * range, 1.f );
|
||||
|
||||
view->setEnableDefaultKeybindings( false );
|
||||
EXPECT_FALSE( view->areDefaultKeybindingsEnabled() );
|
||||
const Float disabledValue = scroll->getValue();
|
||||
press( KEY_SPACE );
|
||||
press( KEY_PAGEUP );
|
||||
press( KEY_PAGEDOWN );
|
||||
press( KEY_HOME );
|
||||
EXPECT_EQ( disabledValue, scroll->getValue() );
|
||||
view->setEnableDefaultKeybindings( true );
|
||||
press( KEY_SPACE, KEYMOD_CTRL );
|
||||
EXPECT_EQ( disabledValue, scroll->getValue() );
|
||||
|
||||
Node* editor = document->find( "entry" );
|
||||
ASSERT_TRUE( editor != nullptr );
|
||||
for ( auto* child = editor->getFirstChild(); child; child = child->getNextNode() ) {
|
||||
if ( child->isType( UI_TYPE_TEXTINPUT ) ) {
|
||||
editor = child;
|
||||
break;
|
||||
}
|
||||
}
|
||||
ASSERT_TRUE( editor->isType( UI_TYPE_TEXTINPUT ) );
|
||||
scene->getEventDispatcher()->setFocusNode( editor );
|
||||
window->getInput()->beginInputFrame();
|
||||
press( KEY_SPACE );
|
||||
EXPECT_EQ( disabledValue, scroll->getValue() );
|
||||
scene->getEventDispatcher()->sendTextInput( ' ', 0 );
|
||||
EXPECT_TRUE( editor->asType<UITextInput>()->getText() == " " );
|
||||
EXPECT_EQ( disabledValue, scroll->getValue() );
|
||||
|
||||
auto* multiline = UITextEdit::New();
|
||||
multiline->setParent( document->getRoot() );
|
||||
scene->getEventDispatcher()->setFocusNode( multiline );
|
||||
window->getInput()->beginInputFrame();
|
||||
press( KEY_SPACE );
|
||||
scene->getEventDispatcher()->sendTextInput( ' ', 0 );
|
||||
EXPECT_TRUE( multiline->getText() == " " );
|
||||
EXPECT_EQ( disabledValue, scroll->getValue() );
|
||||
|
||||
auto* custom = eeNew( TextConsumerWidget, () );
|
||||
custom->setParent( document->getRoot() );
|
||||
scene->getEventDispatcher()->setFocusNode( custom );
|
||||
ASSERT_TRUE( window->isTextInputActive() );
|
||||
press( KEY_SPACE );
|
||||
EXPECT_EQ( disabledValue, scroll->getValue() );
|
||||
scene->getEventDispatcher()->sendTextInput( ' ', 0 );
|
||||
EXPECT_EQ( 1u, custom->acceptedText );
|
||||
EXPECT_EQ( disabledValue, scroll->getValue() );
|
||||
|
||||
custom->consumesText = false;
|
||||
scroll->setValue( 0.f );
|
||||
press( KEY_SPACE );
|
||||
EXPECT_EQ( 0.f, scroll->getValue() );
|
||||
scene->getEventDispatcher()->sendTextInput( ' ', 0 );
|
||||
EXPECT_NEAR( viewport, scroll->getValue() * range, 1.f );
|
||||
InputEvent shiftedSpace( InputEvent::KeyDown );
|
||||
shiftedSpace.key = {};
|
||||
shiftedSpace.key.keysym.sym = KEY_SPACE;
|
||||
shiftedSpace.key.keysym.scancode = SCANCODE_SPACE;
|
||||
shiftedSpace.key.keysym.mod = KEYMOD_LSHIFT;
|
||||
window->getInput()->pushEvent( shiftedSpace );
|
||||
EXPECT_NEAR( viewport, scroll->getValue() * range, 1.f );
|
||||
scene->getEventDispatcher()->sendTextInput( ' ', 0 );
|
||||
EXPECT_EQ( 0.f, scroll->getValue() );
|
||||
shiftedSpace.Type = InputEvent::KeyUp;
|
||||
shiftedSpace.key.keysym.mod = 0;
|
||||
window->getInput()->pushEvent( shiftedSpace );
|
||||
scene->getEventDispatcher()->sendTextInput( ' ', 0 );
|
||||
EXPECT_NEAR( viewport, scroll->getValue() * range, 1.f );
|
||||
view->setEnableDefaultKeybindings( false );
|
||||
scene->getEventDispatcher()->sendTextInput( ' ', 0 );
|
||||
EXPECT_NEAR( viewport, scroll->getValue() * range, 1.f );
|
||||
view->setEnableDefaultKeybindings( true );
|
||||
scroll->setValue( disabledValue );
|
||||
auto* summary = document->find( "summary" );
|
||||
ASSERT_TRUE( summary != nullptr );
|
||||
scene->getEventDispatcher()->setFocusNode( summary );
|
||||
press( KEY_SPACE );
|
||||
EXPECT_EQ( disabledValue, scroll->getValue() );
|
||||
FileSystem::fileRemove( path );
|
||||
Engine::destroySingleton();
|
||||
}
|
||||
|
||||
@@ -198,6 +198,26 @@ UTEST( WebResourceCache, navigationHeadersDoNotDuplicateRetainedEntries ) {
|
||||
EXPECT_EQ( 1u, cache->getEntryCount() );
|
||||
}
|
||||
|
||||
UTEST( WebResourceCache, documentCacheSeparatesCookieSessions ) {
|
||||
auto cache = WebResourceCache::New();
|
||||
int fetches = 0;
|
||||
cache->setFetcher(
|
||||
[&]( const WebResourceRequest&, WebResourceCache::FetchCompletion completion ) {
|
||||
++fetches;
|
||||
auto ok = Http::Response::Status::Ok;
|
||||
completion( response( ok, "document" ) );
|
||||
} );
|
||||
auto session = cache->createSession();
|
||||
auto generation = cache->beginNavigation( session, URI( "https://news.example/" ) );
|
||||
auto request = requestFor( "https://news.example/" );
|
||||
request.kind = WebResourceKind::Document;
|
||||
cache->requestData( session, generation, request, {} );
|
||||
request.headers["Cookie"] = "user=logged-in";
|
||||
cache->requestData( session, generation, request, {} );
|
||||
cache->requestData( session, generation, request, {} );
|
||||
EXPECT_EQ( 2, fetches );
|
||||
}
|
||||
|
||||
UTEST( WebResourceCache, ttlStartsWhenFinalDocumentLeaseIsReleased ) {
|
||||
auto cache = WebResourceCache::New();
|
||||
cache->setTTL( Time::Zero );
|
||||
|
||||
Reference in New Issue
Block a user