-
Notifications
You must be signed in to change notification settings - Fork 162
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add installation prompt control flow #417
Comments
What's the status of spec'ing the Anyway it seems pretty close to what you want to me. Eg. imagine it had been named 'installopportunity' instead. Do developers care to differentiate between the UA choosing to show a banner, vs. the user explicitly finding the menu option? |
@RByers the problem with "beforeinstallprompt" is that it assumes Chrome's UI install flow. That might not match what all UAs do. For example, in Firefox, we are working on "installing" apps into the about:newtab page, which doesn't mandate any install flow. One current solution to this problem is the use the start URL and just add a query string: {
"start_url": "foo/?launched-from=homescreen"
} Another way of detecting if the app has been installed is if the display mode has been applied (using matchMedia). matchMedia("(display-mode: fullscreen)").matches |
I guess the Changing the start_url apparently affects Service Worker caching - or so I heard. |
Using Regarding the initial proposal, we could have an |
Speaking from the outside (I don't have any real context / expertise here) it would be great if we could figure out how to abstract the different use cases into a single API we could all support. Eg. what about an 'installstatuschanged' event with a few possible enum values (not all of which would necessarily be applicable in all scenarios/UAs/platforms) like: "manually installed", "automatic prompt", "prompt accepted", "prompt declined", etc? |
@RByers Yes, that sounds like a good idea. I know that some people also have ideas of tying this in with search engines, so that might open up for a few other options. |
What about
|
Why not simply use the events? The current one ( On Thu, 17 Dec 2015, at 12:41, Kenneth Rohde Christiansen wrote:
|
That is an option, though you could argue that it is not a prompt in all cases (manual, from search engine?) and that doesn't cover uninstall. |
If there is no prompt, there is no Regarding |
Ok, so if I understand you correctly, you want to standardize beforeinstallprompt and additionally add an install event? I am fine with that. You are probably right that uninstall is not that useful. |
@marcoscaceres could you explain more about Firefox's problem with onbeforeinstallprompt? I can imagine if you're automatically installing apps into your new tab page you wouldn't need this event, is there more to it than that? FWIW in Chrome web apps don't know how they have been launched / started, and our suggestion is to use a similar parameter in the start_url, but that seems like a different problem to what onbeforeinstallprompt was designed for. |
Today my web app uses the Chrome prompt, but it also should support Safari in iOS home screen, we created a custom modal telling the users they can save the web app to their home screen.. The problem we had is that there's no way to remove this modal once the users has added the web app to their home screen. This isn't a problem with analytics purposes but implementation. On Safari we have a non-standard
if (!('AddToHomeScreen' in window.navigator)) {
// browser doesn't support it
return
}
if (window.navigator.AddToHomeScreen) {
// icon in the home screen, based on this you can take lots of decisions
} else {
// app isn't in the home screen
// And isn't Chrome, what about showing the user how to add this to its home screen?
} The issue I'd have with @marcoscaceres #417 (comment) mediaMatch solution is that I could use on manifest.json The issue I'd have with only a I'd suggest something like this window.addEventListener('addedToHomeScreen', handleAddedToHomeScreen)
window.navigator.addedToHomeScreen // Boolean // Easy to check for browser support
function handleAddedToHomeScreen() {
// Send analytics convertion, this user seems to love our web app
} |
It sounds to me like we're converging on introducing a new @felquis - I see the desire for the boolean too, although I worry that it's overly simple since the user may have installed the site but since uninstalled it (which Chrome is unable to detect today, FWIW), or visited it now in browser mode for some reason despite having installed it previously. I suggest for now that you listen for the event and write it to localstorage, which provides basically what you want but without requiring another piece of standardization. I suggest we go ahead with standardizing the new event. Sound good? |
Ok, I support what @owencm is saying: being able to check, in the sense of a boolean, if an app is installed is going to be very hard (if not impossible) to implement on various OSs... as @owencm also points out, it would need to be done async, because the user could trash the icon right after install - or at any point... if at all possible, the UA would need to somehow check if the icon it dropped on the homescreen is still present (which means at least IO or IPC somewhere). I also understand the value of |
Let's add it then. Does the "isInstalled" boolean have to always be 100% up to date or could it be a cached value? |
"It" being "onbeforeinstall", allowing UAs to not necessarily make us of this. This event is cancellable, does not bubble, etc. Chrome folks, let me know what you do here exactly and I'll spec it. And "install" event, happens on successful "installation" (i.e., whatever that means to the UA, as to where it allows the user to access the "installed" web application). This event is not cancellable, does not bubble, etc.
I say we punt on |
Marcos, do you mean |
Sorry, I meant beforeinstallprompt.
|
@marcoscaceres Chrome Platform Status suggests explainer.md is the documentation for Without a strong use case I'd punt |
@anssiko, the layout tests have been updated for the latest changes, this one is probably the best one to look at. The explainer.md is a little out of date, so the tests are probably a better reference for what Chrome has implemented. Specific things I noticed: |
@benfredwells, this is super helpful! thanks. |
@marcoscaceres If you're looking at speccing this feature I'm happy to review and contribute. @benfredwells Thanks for confirming the layout tests' status. |
Will start on this tomorrow. |
ProposalOk, so we really want to deal with 2 cases here:
The Chrome proposal only deals with 2, so I think that is insufficient for us to standardize on. Case 1 - the user initiates the install manuallyThis action is non-cancellable, occurs independently of the application (i.e., it is not observable), but the application should still be notified that it was "installed". Solution: install event. This simple event fires after the UA has installed the application. What "install" means is left up to the UA, but generally means that the icon and application name has been added to the homescreen (or other place where apps are installed, like about:newtab). Case 2 - UA initiated install(mostly the Chrome case)
What do people think? Question: should we deal with install errors or punt on them for now? IDLenum InstallationChoice {
"installed",
"dismissed",
"denied",
};
interface BeforeInstallEvent : Event {
Promise<InstallationChoice> prompt();
};
[NoInterfaceObject]
interface AppInstallEventsMixin {
attribute EventHandler onbeforeinstallprompt;
attribute EventHandler oninstall;
};
window implements AppInstallEventsMixin; |
@owencm, @RByers, as OPs, would like to hear your thoughts on #417 (comment). @mounirlamouri, how would the above sit with you? Would you be willing to change your implementation? |
Regarding: "dismissed" vs "denied" - dismissed would be the user not explicitly saying they don't want to install the application (e.g., clicking a close button, or pressing the "esc" key). "denied" is effectively saying "no, thanks!... and don't bug me again about this site". They are different signals and it's probably important to distinguish between them: high dismissal rates might be indicative that something is wrong with the UX (or prompting might be occurring at a bad time), for example. |
Slightly off topic: Dismissed probably will mean that it will reappear next time the site is loaded which may make some people say "no thanks" if busy with other things (ie, driving - when you really shouldn't use your phone :)). In a way, I would like a 'not now' but maybe we could do some recommendations to dismiss the install prompts at least for a while (one hour?) |
@mgiuca, I agree. I'll make some modification to match the above. |
@mgiuca, I think then I need to turn window.addEventListener('beforeinstallprompt', (ev) => {
try {
e.prompt(); // Error: preventDefault not called
} catch (e) {}
e.preventDefault();
e.prompt();
await e.userChoice; // let's say 5 seconds later this resolves...
});
// Second one... runs immediately after the first one
window.addEventListener('beforeinstallprompt', (ev) => {
e.prompt(); // Noop
e.prompt(); // Noop
setTimeout()=>{
e.prompt(); // Throws, because already consumed.
}, 10000); // 10 seconds later
}); |
@mgiuca, @dominickng - ok, hopefully N-th time lucky :) So, I think we actually do want to throw InvalidStateError if "prompting". So this would be cover all cases discussed so far (tested with @mgiuca test above too). prompt() {
if (this.isTrusted === false) {
const msg = "Untrusted events can't call prompt().";
throw new DOMException(msg, "NotAllowedError");
}
let msg = "";
switch (internalSlots.get(this).promptState) {
case "done":
msg = ".prompt() has expired.";
throw new DOMException(msg, "InvalidStateError");
case "prompting":
msg = "Already trying to prompt.";
throw new DOMException(msg, "InvalidStateError");
default:
if (this.defaultPrevented === false) {
msg = ".prompt() needs to be called after .preventDefault()";
throw new DOMException(msg, "InvalidStateError");
}
internalSlots.get(this).promptState = "prompting";
}
(async function task() {
const promptOutcome = await showInstallPrompt();
internalSlots.get(this).promptState = "done";
internalSlots.get(this).userChoiceHandlers.resolve(promptOutcome);
}.bind(this)())
} |
Sorry, pasted the wrong version ... repasted the one above. |
I'm not sure we need a state machine: the "prompting" and "done" states aren't both needed... they are indistinguishable except for the error message so we can merge them into one and are back with a Boolean again. (Actually, it'd be nice to work defaultPrevented into a multi-state machine, but since it's a Boolean in the underlying platform, let's not.) So prompt becomes:
And if the prompt is automatically shown, it also sets didPrompt to true. Does it need to be more complicated than that? |
Yeah, you are right. I was over complicating it. |
@dominickng, @mgiuca, @mounirlamouri , I'm still a little bit confused by this part of the spec: if (this.defaultPrevented === false) {
const msg = ".prompt() needs to be called after .preventDefault()";
throw new DOMException(msg, "InvalidStateError");
} Because, you can immediately do: ev.preventDefault();
ev.prompt().then(...); Why don't we just treat such .prompt() calls as normal? So: addEventListener("beforeinstallprompt", async function(ev) {
const { userChoice } = await ev.prompt();
console.log("user chose:", userChoice);
} ); I'm probably missing something... but that would be nicer than throwing so many errors. |
Yeah I actually agree about that. I think the only argument for it is that you're supposed to use preventDefault-prompt as a pair to delay showing the banner. If you call prompt without preventDefault, it's essentially a no-op and I think we just put that error there because it's "nonsensical". But:
So I'd say take out that error. It also doesn't break any sites if we remove it. |
I'll make the change above, but need confirmation about removing the Can I get an ok that |
I asked about this on the Chrome bug tracker. I'm happy for it to go away (or be deprecated) but I want to wait for Mounir's response. |
Ok, seems @mounirlamouri is ok with the deprecation. Updating spec and ref-implementation now... |
So, playing around with the updated implementation... I'm now wondering if we need to error on when Consider, a custom BIP that has the // This sets the internal [[promptOutcome]] immediately:
const bip = new BeforeInstallPromptEvent("beforeinstallprompt", {"userChoice": "accepted"});
bip.prompt().then(({userChoice}) => userChoice === "accepted");
bip.prompt().then(({userChoice}) => userChoice === "accepted");
// And so on... So you can call Now consider a real/trusted event.defaultPrevented();
// Some time later
const p = event.prompt(); // 1. IPC request, internally waiting.
const p2 = event.prompt(); // 2. We are waiting, so just return a new promise and wait.
// Wait, these just resolve to the same thing... (e.g., "accepted")
Promise.all([p1, p2]).then(results => results.every("accepted") === true);
setTimeout(()=>{
// We can even check later what it resolved to...
ev.prompt().then({userChoice} => console.log(userChoice))
}, 10000); The drawback of the above is that we need to keep a queue of promises that are waiting an outcome. I don't have a strong opinion - we can keep "[[didPrompt]]" as a guard to throw InvalidStateError if already prompting. However, I do want to allow resolving Thoughts? |
I see, so you're saying that you can call
Can't we just have prompt() always return the same promise? In effect, it just returns the userChoice promise that we used to have as an attribute. Then surely we don't need a queue. I think the reason for that error is that if you call So I'd be happy for:
To have both promises succeed, but
to have the second prompt fail. Does that sound OK? |
Yep.
You are probably right - but I need to check what the WebIDL binding layer does. I thought the call would need to return a new object for some reason.
This one could just resolve with the results of whatever "Click install" was. That would still adhere to only being able to actually |
Yeah but it would maybe be a bit misleading to have it effectively fail silently. I don't feel super strongly about it. |
Ok, so got confirmation that it's fine to return the same promise from a method... I know, it sounds silly in hindsight that it would not be. Thanks @mgiuca for catching that out. |
This is last call for review of BeforeInstallPromptEvent: https://github.com/w3c/manifest/pull/520/files You can still comment after we merge, but prefer comments sooner rather than later so implementation can happen. |
Hi Marcos, I spoke with Owen and Alex on Friday and we have come to an agreement that we can continue with the beforeinstallprompt API mostly as it is now (in #520), but with a few wording changes to the spec to allow UAs more freedom to experiment and change their UI. Let me summarize what we've been talking about: we have some concern that our UI is not working; feedback from developers indicates that they want to be able to show the prompt more easily and reliably. A particular concern was that once users cancel the banner, they aren't able to have a button that shows the banner again (they have to wait for another BIP event). Maybe we want to, say, give 3 denies before we give the domain a long time penalty before prompting is allowed again. The current BIP doesn't naturally allow reprompting immediately after a deny. We have to balance this with spammy behaviour, of course, but we want to keep the UI options open. For the reprompting case, we have 3 options:
Since we previously decided we wanted to work within the existing BIP framework, we decided that #2 is an OK option. #1 seemed wrong because it distorts the BIPE object into this long-lived object that you can use many times. But if we fire a BIP after a failed So in summary:
Thoughts on this? |
I need to give this more thought... but my initial reaction is that calling prompt() multiple times or firing multiple events doesn't feel right. Rough sketch here, if we were to keep backwards compat with current Chrome implementation - but actually just give developers what they are asking for: async () => {
let userChoice;
// We add navigator.canPromptForInstall
// that waits for installability signal
// Resolves to true or false, because end-user may tell UA "don't bug me with installs, ever!"
if (await navigator.canPromptForInstall) {
// BIP compat layer - except, browser no longer fire this on their own!
window.beforeintallprompt = ev => {
if (thingsThatBlockInstall.length === 0) {
return;
}
ev.preventDefault();
await Promise.all(thingsThatBlockInstall);
ev.prompt(); // show it... it settles "navigator.requestInstallPrompt()"
}
await Promise.all(thingsThatBlockInstall);
userChoice = await navigator.requestInstallPrompt();
}
}(); |
It sounds like you're suggesting we have both the global method and the existing beforeinstallprompt event. So in the reprompt case, you're saying that we would only fire BIP once, but then you could call
We'd still want the browser to fire this event on its own, or otherwise it would be backwards-incompatible with existing sites (which would need to now call requestInstallPrompt in order to trigger the BIP?) |
Yes... for backwards compat. I hadn't thought this through...
Yeah, and then we can rate limit, etc.
Yeah, essentially, bipe.prompt() would have just called .requestInstallPrompt().
Yeah, but it would allow for a gradual phase out of bipe: we (browsers) make no guarantees that a site will continue to meet the criteria for an automated BIPE to be fired. Over time, we could essentially get that number right down to 0.01% or whatever, because no site will meet the criteria to fire the event. There was never a guarantee at all the BIPE would fire in the future. |
To be clear - I think this is what we want: async function tryToInstall() {
if (!("canPromptForInstall" in navigator) || !(await navigator.canPromptForInstall)) {
return; // Computer says no.
}
await Promise.all(thingsThatBlockInstall);
try {
const userChoice = await navigator.requestInstallPrompt();
doWhateverWith(userChoice);
} catch (err) {
// catch in case we missed our window of opportunity,
// or we called it too soon after page load,
// or we called it without user interaction,
// or we got rate-limited,
// or an invalid state because user is doing it manually now,
// or whatever...
}
}
installButton.onclick = tryToInstall; |
OK. I remember Alex had an objection to the global method -- I don't remember whether it was just due to churn or if there was an actual objection too. I'll ask him again. If we have both APIs available that should be better?? |
I've heard some feedback from developers that they are very excited by encouraging users to press the 'Add to Home Screen' button in Chrome, and the equivalents in Opera etc, but that it is a problem that they can't track that event for analytics purposes.
It would be great if we could somehow expose to developers that the user has installed their web app via some UA-provided button.
We could create a new event for this, or one alternate idea would be to fire a non-cancellable BeforeInstallPrompt event which immediately resolves the
userChoice
promise whenever the user presses a UA-provided button to install the web app.The text was updated successfully, but these errors were encountered: