Skip to content

Commit 0f9d2b8

Browse files
author
hhaensel
committed
add redirect!, goto, is_reactive
1 parent d186fe3 commit 0f9d2b8

1 file changed

Lines changed: 242 additions & 37 deletions

File tree

src/GenieTest.jl

Lines changed: 242 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
module GenieTest
22

33
using Test
4-
export App, wait_for, notify_test, @App
4+
export App, wait_for, notify_test, @App, connect!, redirect!, goto, is_reactive
55

66
using Reexport
77
@reexport using Stipple
@@ -58,7 +58,7 @@ function Base.notify(win::Window, field::Symbol, priorities = nothing)
5858
# no listener priorities available on the client side, so only evaluate priorities for level 0
5959
priorities isa Function && priorities(0) === false && return false
6060

61-
run(app.__window__, js"""window?.GENIEMODEL?.push('$field')"""i)
61+
run(win, js"""window?.GENIEMODEL?.push('$field')"""i)
6262
end
6363
6464
function Base.notify(win::Window, message::AbstractString, type::Union{Nothing, String, Symbol} = nothing; kwargs...)
@@ -71,12 +71,24 @@ Base.@kwdef mutable struct App
7171
__model__::Union{ReactiveModel, Nothing} = nothing
7272
__window__::Union{Window, Nothing} = nothing
7373
__priority__::Symbol = :model
74+
__id__::String = ""
7475
__url__::String = ""
7576
__electron_options__::Dict{String, Any} = Dict{String, Any}()
7677
__timeout__::Float64 = 30.0
7778
__port__::Union{Int, Nothing} = nothing
7879
end
7980
81+
function remove_id(url::Union{String, URI})::URI
82+
uri = URI(url)
83+
query = replace(uri.query, r"debug_id=[^&/]+&?" => "")
84+
if isempty(query)
85+
# explicitly adding an empty query adds a '?' at the end
86+
url = URI(chopsuffix(string(URI(uri; query = "")), '?'))
87+
else
88+
URI(uri; query)
89+
end
90+
end
91+
8092
const AppDict = Dict{Any, App}
8193
8294
function Base.getproperty(app::App, fieldname::Symbol)
@@ -98,10 +110,10 @@ function Base.getproperty(app::App, fieldname::Symbol)
98110
field = getfield(model, fieldname)
99111
field isa Reactive ? field[] : field
100112
end
101-
elseif app.__window__ !== nothing
113+
elseif app.__window__ !== nothing && app.__window__.exists
102114
run(app.__window__, unproxy("window?.GENIEMODEL?.$fieldname"))
103115
else
104-
@warn("App has neither model nor window")
116+
@warn("App has neither model nor active window")
105117
end
106118
end
107119
@@ -185,6 +197,11 @@ function Base.notify(app::App, message::AbstractString, type::Union{Nothing, Str
185197
end
186198
end
187199
200+
function add_id(url::Union{String, URI}, id::String)
201+
uri = URI(url)
202+
isempty(id) ? URI(uri) : URI(uri, query = join(filter(!isempty, ["debug_id=$id", uri.query]), '&'))
203+
end
204+
188205
"""
189206
App(url::String = "/";
190207
timeout::Float64 = 30,
@@ -217,44 +234,50 @@ Create a Stipple App with optional frontend and backend.
217234
# Returns
218235
An `App` instance containing the backend model and the frontend window.
219236
"""
220-
function App(url::String;
237+
function App(url::Union{String, URI};
221238
timeout::Real = 30,
222239
port = nothing,
223240
id::String = string(uuid4()),
224-
backend::Bool = !startswith(url, r"https://"i),
225-
frontend::Symbol = startswith(url, r"https://"i) || !backend ? :electron : :browser,
241+
backend::Bool = !startswith(string(url), r"https://"i),
242+
frontend::Symbol = startswith(string(url), r"https://"i) || !backend ? :electron : :browser,
226243
isready::Function = app -> app.isready === true,
227244
electron_options::Dict{String, <:Any} = Dict{String, Any}(),
228-
priority::Symbol = :model
245+
priority::Symbol = :model,
246+
window::Union{Window, Nothing} = nothing
229247
)
230248
port === nothing && (port = Genie.config.server_port)
231249
println()
232250
@info "-------------- Starting App --------------"
233-
startswith(url, r"https?://"i) || (url = "http://localhost:$port/" * strip(url, '/'))
234-
url = URI(url)
235-
url = URI(url, query = join(filter(!isempty, ["debug_id=$id", url.query]), '&'))
236-
win = if frontend == :electron
251+
uri = URI(url)
252+
isempty(uri.scheme) && (uri = URI(uri; scheme = "http", host = "localhost", port, path = string('/', strip(uri.path, '/'))))
253+
final_uri = add_id(uri, id)
254+
255+
win = if window !== nothing && window.exists
256+
Electron.load(window, final_uri)
257+
window
258+
elseif frontend == :electron
237259
# default to sandbox mode
238260
electron_options = Dict{String, Any}(electron_options)
239261
wp = get!(electron_options, "webPreferences", Dict{String, Any}())
240262
electron_options["webPreferences"] = merge(Dict{String, Any}("sandbox" => true), wp)
241263
242-
Window(url, options = electron_options)
264+
Window(final_uri, options = electron_options)
243265
elseif frontend == :browser
244-
Genie.Server.openbrowser(url)
266+
Genie.Server.openbrowser(final_uri)
245267
nothing
246268
else
247-
HTTP.get(url)
269+
HTTP.get(final_uri)
248270
nothing
249271
end
272+
250273
model = nothing
251274
252275
if backend
253276
model = Stipple.debug_model(id; timeout)
254277
frontend == :none && (model.isready[] = true)
255278
end
256279
257-
app = App(model, win, priority, "$url", electron_options, float(timeout), port)
280+
app = App(model, win, priority, id, "$uri", electron_options, float(timeout), port)
258281
if model === nothing && win === nothing
259282
@warn("App has neither frontend nor backend")
260283
return app
@@ -288,16 +311,70 @@ end
288311
289312
App(context::Module) = App(@eval context Stipple.@type)
290313
314+
"""
315+
App(app::App, args...;
316+
timeout::Real = app.__timeout__,
317+
port = app.__port__,
318+
id::String = app.__id__,
319+
backend::Bool = app.__model__ !== nothing,
320+
frontend::Symbol = app.__window__ !== nothing ? :electron : :none,
321+
isready::Function = app -> app.isready === true,
322+
electron_options::Dict{String, <:Any} = app.__electron_options__,
323+
priority::Symbol = app.__priority__,
324+
window::Union{Window, Nothing} = app.__window__
325+
)
326+
327+
Reinitialize an existing app with new settings or URL.
328+
329+
This method allows you to update an existing `App` instance by creating a new app
330+
with different parameters and then transferring all fields to the original instance.
331+
All keyword arguments default to the current app's settings.
332+
333+
# Arguments
334+
- `app::App`: The existing app instance to reinitialize.
335+
- `args...`: Additional positional arguments (typically a new URL) passed to the main `App` constructor.
336+
337+
# Keyword Arguments
338+
- `timeout::Real`: Timeout in seconds for app initialization.
339+
- `port`: Port where the Genie server is running.
340+
- `id::String`: Debug ID for the app.
341+
- `backend::Bool`: Whether the backend model should be active.
342+
- `frontend::Symbol`: Frontend type (`:electron`, `:browser`, or `:none`).
343+
- `isready::Function`: Function to check if the backend is ready.
344+
- `electron_options::Dict{String, <:Any}`: Options for the Electron window.
345+
- `priority::Symbol`: Priority for getting/setting properties (`:model` or `:window`).
346+
- `window::Union{Window, Nothing}`: Existing window to reuse.
347+
348+
# Returns
349+
The modified `App` instance.
350+
"""
351+
function App(app::App, args...;
352+
timeout::Real = app.__timeout__,
353+
port = app.__port__,
354+
id::String = app.__id__,
355+
backend::Bool = app.__model__ !== nothing,
356+
frontend::Symbol = app.__window__ !== nothing ? :electron : :none, # use the existing window
357+
isready::Function = app -> app.isready === true,
358+
electron_options::Dict{String, <:Any} = app.__electron_options__,
359+
priority::Symbol = app.__priority__,
360+
window::Union{Window, Nothing} = app.__window__
361+
)
362+
a = App(args...; timeout, port, id, backend, frontend, isready, electron_options, priority, window)
363+
for field in fieldnames(App)
364+
setfield!(app, field, getfield(a, field))
365+
end
366+
end
367+
291368
macro App()
292369
:(App(@__MODULE__))
293370
end
294371
295372
function Base.propertynames(app::App)
296373
if app.__model__ !== nothing
297374
tuple(propertynames(app.__model__)..., fieldnames(App)...)
298-
elseif app.__window__ !== nothing
375+
elseif app.__window__ !== nothing && app.__window__.exists
299376
fnames = run(app.__window__, """
300-
(x => Object.keys(x).filter(k => typeof x[k] !== 'function' && k !== 'WebChannel'))(window.GENIEMODEL || {})
377+
(x => Object.keys(x).filter(k => typeof x[k] !== 'function' && k !== 'WebChannel'))(window?.GENIEMODEL || {})
301378
""")
302379
tuple(Symbol.(fnames)..., fieldnames(App)...)
303380
else
@@ -307,6 +384,10 @@ end
307384
308385
function Base.run(app::App, msg::Union{AbstractString, JSONText}; timeout = 1, clone_result::Union{Bool, Nothing} = true)
309386
if app.__window__ !== nothing
387+
if ! app.__window__.exists
388+
@warn "Cannot run code on window, it probably has been closed."
389+
return nothing
390+
end
310391
msg isa JSONText && (msg = json(msg))
311392
if clone_result === nothing # automatically clone if necessary
312393
try
@@ -378,27 +459,151 @@ function notify_test(app::App, test::Test.Result, test_str::AbstractString = "Te
378459
end
379460
end
380461
462+
function merge_uri(base_uri::URI, new_uri::URI)
463+
!isempty(new_uri.scheme) && return new_uri
464+
465+
base_kwargs = filter(!isempty ∘ last, Dict(k => getfield(base_uri, k) for k in fieldnames(URI) if k ∉ [:uri, :query, :fragment]))
466+
new_kwargs = filter(!isempty ∘ last, Dict(k => getfield(new_uri, k) for k in fieldnames(URI) if k !== :uri))
467+
new_path = if startswith(new_uri.path, '/') || base_uri.path == ""
468+
string('/', chopprefix(new_uri.path, "/"))
469+
else
470+
base_url = string('/', chopprefix(base_uri.path, "/"))
471+
relative_url = chopprefix(new_uri.path, "/")
472+
join(filter(!isempty, [base_url, relative_url]), '/')
473+
end
474+
new_kwargs[:path] = new_path
475+
merge!(base_kwargs, new_kwargs)
476+
URI(; base_kwargs...)
477+
end
478+
479+
"""
480+
redirect!(app::App, url::Union{String, URI}; id = app.__id__)
481+
482+
Redirect the app to a new URL while preserving the app state and optionally changing the debug ID.
483+
484+
This function updates the app's internal URL state and performs a redirect in the frontend.
485+
If the URL is relative, it will be merged with the current base URL. The function will
486+
attempt to update the window location, or recreate the app connection if necessary.
487+
488+
# Arguments
489+
- `app::App`: The app instance to redirect.
490+
- `url::Union{String, URI}`: The new URL to navigate to. Can be relative or absolute.
491+
492+
# Keyword Arguments
493+
- `id = app.__id__`: Debug ID for the new page. Defaults to the current app ID.
494+
495+
# Returns
496+
The modified `App` instance.
497+
"""
498+
function redirect!(app::App, url::Union{String, URI}; id = app.__id__)
499+
uri = merge_uri(URI(app.__url__), URI(url))
500+
if isempty(uri.scheme)
501+
uri = URI(app.__url__; path = join(uri.path))
502+
end
503+
app.__url__ = "$uri"
504+
app.__id__ = id
505+
final_uri = add_id(uri, id)
506+
if run(app, "window.location = '$final_uri'") === nothing
507+
App(app, final_uri)
508+
end
509+
return app
510+
end
511+
512+
"""
513+
goto(app::App, url::Union{String, URI}; id = app.__id__)
514+
515+
Navigate the app to a new URL by directly setting the window location.
516+
517+
Unlike `redirect!`, this function does not update the app's internal state and performs
518+
a simple navigation by setting the window location. The URL is not merged with the base URL.
519+
520+
# Arguments
521+
- `app::App`: The app instance to navigate.
522+
- `url::Union{String, URI}`: The URL to navigate to.
523+
524+
# Keyword Arguments
525+
- `id = app.__id__`: Debug ID to append to the URL. Defaults to the current app ID.
526+
527+
# Returns
528+
The result of the JavaScript execution (typically `nothing`).
529+
"""
530+
function goto(app::App, url::Union{String, URI}; id = app.__id__)
531+
uri = add_id(url, id)
532+
run(app, "window.location = '$uri'")
533+
end
534+
535+
"""
536+
is_reactive(app::App)
537+
538+
Check if the app's reactive model is ready and available.
539+
540+
This function queries the frontend to determine if the Genie reactive model is loaded
541+
and ready for interaction. It checks the `window.GENIEMODEL.isready` property.
542+
543+
# Arguments
544+
- `app::App`: The app instance to check.
545+
546+
# Returns
547+
`true` if the reactive model is ready, `false` otherwise (including if an error occurs).
548+
"""
549+
function is_reactive(app::App)
550+
try
551+
run(app, "window?.GENIEMODEL?.isready || false")
552+
catch
553+
false
554+
end
555+
end
556+
557+
"""
558+
connect!(app::App; timeout = nothing, port = nothing, isready::Function = app -> app.isready === true)
559+
560+
Ensure the app is connected and recreate the window if it has been closed.
561+
562+
This function checks if the app's window exists and attempts to recreate it if it has been
563+
closed. If the window is already available, it returns `true` immediately.
564+
565+
# Arguments
566+
- `app::App`: The app instance to connect or reconnect.
567+
568+
# Keyword Arguments
569+
- `timeout = nothing`: Timeout in seconds for waiting for the app to be ready.
570+
If `nothing`, uses the app's current timeout setting.
571+
- `port = nothing`: Port where the Genie server is running. If `nothing`, uses
572+
the app's current port setting.
573+
- `isready::Function = app -> app.isready === true`: Function to check if the
574+
backend is ready.
575+
576+
# Returns
577+
`true` if the connection is successful or if the window already exists, `false` if
578+
recreation fails.
579+
"""
381580
function connect!(app::App; timeout = nothing, port = nothing, isready::Function = app -> app.isready === true)
382-
if app.__window__ !== nothing && !app.__window__.exists
383-
@info "App window appears to be closed. Recreating the window..."
384-
try
385-
a = App(
386-
app.__url__,
387-
frontend = :electron,
388-
electron_options = app.__electron_options__,
389-
priority = app.__priority__,
390-
backend = app.__model__ !== nothing,
391-
timeout = timeout === nothing ? app.__timeout__ : timeout,
392-
port = port === nothing ? app.__port__ : port,
393-
)
394-
app.__model__ = a.__model__
395-
app.__window__ = a.__window__
396-
app.__port__ = a.__port__
397-
app.__timeout__ = a.__timeout__
581+
if app.__window__ !== nothing
582+
if !app.__window__.exists
583+
@info "App window appears to be closed. Recreating the window..."
584+
try
585+
a = App(
586+
app.__url__,
587+
id = app.__id__,
588+
frontend = :electron,
589+
electron_options = app.__electron_options__,
590+
priority = app.__priority__,
591+
backend = app.__model__ !== nothing,
592+
timeout = timeout === nothing ? app.__timeout__ : timeout,
593+
port = port === nothing ? app.__port__ : port,
594+
)
595+
app.__model__ = a.__model__
596+
app.__window__ = a.__window__
597+
app.__port__ = a.__port__
598+
app.__timeout__ = a.__timeout__
599+
true
600+
catch e
601+
@warn "Failed to recreate app window: $e"
602+
false
603+
end
604+
else
605+
# the window is available, but the content has been
398606
true
399-
catch e
400-
@warn "Failed to recreate app window: $e"
401-
false
402607
end
403608
else
404609
true

0 commit comments

Comments
 (0)