Manifest: Add installation prompt control flow

Created on 16 Dec 2015  Â·  123Comments  Â·  Source: w3c/manifest

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.

Feature Request P1

Most helpful comment

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?

All 123 comments

What's the status of spec'ing the beforeinstallprompt event? It shipped in blink based only on the explainer which doesn't seem very precise about the current design.

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 beforeinstallprompt could be added in a non-normative section?

Changing the start_url apparently affects Service Worker caching - or so I heard.

Using start_url or matchMedia only gives information about usage of added to homescreen web apps, not about how often they are added. Users might add web apps but never actually use them or the other way around.

Regarding the initial proposal, we could have an install event which could be fired whenever the app is installed. Not entirely a big fan of this but we kind of opened the door for this when adding beforeinstallprompt.

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

engagementchange(EngagementChangeReason reason)

enum EngagementChangeReason {
  "uninstalled",
  "installed-manually",
  "install-prompt-shown",
  "install-prompt-cancelled",
  "install-prompt-accepted",
  "install-prompt-denied"
}

Why not simply use the events? The current one (beforeinstallprompt)
combined with installed should offer the same information.

On Thu, 17 Dec 2015, at 12:41, Kenneth Rohde Christiansen wrote:

What about

engagementchange(EngagementChangeReason reason)

enum EngagementChangeReason {
  "uninstalled",
  "installed-manually",
  "install-prompt-shown",
  "install-prompt-cancelled",
  "install-prompt-accepted",
  "install-prompt-denied"
}

Reply to this email directly or view it on GitHub:
https://github.com/w3c/manifest/issues/417#issuecomment-165442341

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 beforeinstallprompt event but there is an install event.

Regarding uninstall, I'm not sure this event makes a lot of sense: would you open the web app to tell it has been uninstalled? (FWIW, Chrome wouldn't be able to implement this.)

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 window.navigator.standalone which is terrible, but other informations on navigator would be appreciated to answer these question.

  1. Does the user has this web app on the their home screen?
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 https://github.com/w3c/manifest/issues/417#issuecomment-164981366 mediaMatch solution is that I could use on manifest.json "display": "browser", not only "display":"standalone".

The issue I'd have with only a installed events, is that is will be fired once, then later I'll need to save it to my localStorage, or make some other workaround to know if the user has installed it or not

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 install event (subject to name bikeshedding).

@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, install is growing on me. Basically, install translates to successfully dropping an icon + name of app somewhere.

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 onbeforeinstall and us our implementation on Android ramps up, it will likely mean that Fennec will need this event also: however, I don't know yet how our Android UX team will want to implement the install flow for a web app (i.e., if they will need it at all, or if they will do something similar to Chrome). I hope I can answer that in the next few months.

Let's add it then.

Does the "isInstalled" boolean have to always be 100% up to date or could it be a cached value?

Let's add it then.

"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.

Does the "isInstalled" boolean have to always be 100% up to date or could it be a cached value?

I say we punt on isInstalled. It just won't be reliable.

Marcos, do you mean beforeinstall or beforeinstallprompt?

On 15 Feb 2016, at 7:43 PM, Mounir Lamouri [email protected] wrote:

Marcos, do you mean beforeinstall or beforeinstallprompt?

Sorry, I meant beforeinstallprompt.

—
Reply to this email directly or view it on GitHub.

@marcoscaceres Chrome Platform Status suggests explainer.md is the documentation for beforeinstallprompt. LayoutTests should tell what is implemented (assuming test coverage is good, @mounirlamouri?).

Without a strong use case I'd punt e.platforms for later. It exposes the related_applications that "are provided as options in an install prompt".

@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: accepted is returned for completed installs, not installed, and e.prompt() returns a promise, which explainer.md doesn't explain.

@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.

Proposal

Ok, so we really want to deal with 2 cases here:

  1. the user initiates the install manually.
  2. the UA initiates the install automatically.

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 manually

This 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)

  • This action must be programmatically cancellable (user may be doing something important, like driving) and shouldn't be disturbed by an install prompt. So, .preventDefault() applies.
  • It is still important to recover from the point above, so the application must be able to re-.prompt();.
  • prompt() vends a promise. The prompt's promise settles with either "installed", "denied", "dismissed".
  • the "install" event fires when installation is successful.

What do people think?

Question: should we deal with install errors or punt on them for now?

IDL

enum 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 https://github.com/w3c/manifest/issues/417#issuecomment-200192679.

@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?)

@marcoscaceres Thanks for the proposal. Is the promise returned by prompt() fulfilled with "installed" when the installation actually succeeds (analogous to install event firing) or when the user clicks the "install the app" button (in which case the installation can still fail)? I feel the former would be better.

@anssiko, yes, the former. Sorry for not being clear.

@mounirlamouri asked:

Maybe you could mention the reasons regarding the differences?

Mainly, the Chrome implementation fails to handle case 1.

Also, there are a number of unclear things about the Chrome implementation. Like, why is the platform needed? Won't the platform always just match the platform you are on (e.g., As a user: I'm in Chrome on Android; I see the app banner and install the Android app... I can't install the iOS app, as I'm, you know, on Android!?).

The same applies to BeforeInstallPromptEventInit, why do you need the platform there?

Why do you need both Promise<void> prompt() and .userChoice, when prompt() can answer the question for you?

@mounirlamouri, the Chrome proposal also conflates denied and dismissed. Firefox non-modal prompts use 3 states... yes, it's a bit of a nightmare... don't get me started.

Like, why is the platform needed? Won't the platform always just match the platform you are on (e.g., As a user: I'm in Chrome on Android; I see the app banner and install the Android app... I can't install the iOS app, as I'm, you know, on Android!?).

In that case there are two possible platforms: web and android. With the related_applications manifest field web apps can have both related non-web apps (e.g. Android) as well as the web app itself. Both of these could be installed.

The prefer_related_applications field allows a web app to give a hint about which platform it would prefer to have installed, but UAs are under no obligation to follow that hint.

Quick update... currently working on this in the "install_event" branch. Hope to have a PR soon.

@felquis requested support for a specific use case "Detect if the user has the app installed" which was de-prioritized in favor of implementing the install events.

Would anyone be opposed to me logging a new issue with this explicit requirement?

My rationale is that the Web Payments WG [1] is considering making app manifests a central part of how Payment Apps [2] will work and we have a similar use case.

[1] https://github.com/w3c/webpayments/
[2] https://w3c.github.io/browser-payment-api/specs/architecture.html#payment-apps

@adrianhopebailie asked:

Would anyone be opposed to me logging a new issue with this explicit requirement?

No objections. Please file a new bug and we can pick that up there.

I'm having trouble deciding what to do if the installation process fails: That is, once the user makes a choice to proceed with installation, it can fail because: out of space, security error, etc. It doesn't seem right to me to make .prompt()'s promise handle this case, as was previously suggested by @slightlyoff.

If we are going to handle it at all, I feel there should be an window.oninstallerror event handler instead.

Thoughts @mounirlamouri, @kenchris, @anssiko ?

Over on Moz's Bugzilla, @bzbarsky pointed out that BeforeInstallPromptEvent has a pretty serious issue:

So the idea is that you can only prompt if no one else has prevented
you from doing so? And hence the behavior is event listener ordering
dependent? :(

Boris is right and that's indeed pretty borked. @mounirlamouri, @slightlyoff, we might need to come up with some other way to deal with beforeinstallprompt.

I might separate install into its own bug, as that event should not be controversial.

I've spun off the install event to https://github.com/w3c/manifest/issues/455 and sent a PR #453. @owencm, your review would be appreciated.

Hi, I'd like to pick up where this discussion left off in April (since it seems we dropped the ball on the Chrome side). I've chatted with Dom, Mounir, Ben, Alex and Owen (all from Chrome) to get up to speed.

Here's the state of play wrt beforeinstallprompt (BIP) and install events:

  • BIP is implemented and available by default on Chrome (and has been since Chrome 44, in 2015).
  • BIP was briefly played with in the spec by Marcos (c9d2df5a) but taken out in favour of install.
  • install has been implemented in Firefox 49 (not yet stable).

It looks like install is going ahead and it's relatively simple and non-controversial, so I've started implementation in Chrome (I just sent an Intent to Implement email today; Chrome tracking bug).

However, we still think there's value in BIP. Last signal from Marcos on this was:

we might need to come up with some other way to deal with beforeinstallprompt.

I don't think install satisfies this requirement. We want a way to report to the website _before_ showing the install prompt, to give the site an opportunity to suppress the banner and expose it to the user at a more convenient time (e.g., instead of it popping up in the user's face, it can provide a banner/button to install).

Now I'm trying to understand what Boris's objections are to the current implementation of BIP in Chrome. I don't really understand this:

So the idea is that you can only prompt if no one else has prevented
you from doing so? And hence the behavior is event listener ordering
dependent? :(

My understanding is that you can only prompt iff you've previously called preventDefault on the event. So it seems more like the "event listener ordering dependent" problem is if you have two listeners, one calls preventDefault and the other calls prompt:

window.addEventListener('beforeinstallprompt', e => e.preventDefault());
window.addEventListener('beforeinstallprompt', e => e.prompt());

Now in this particular case, it's unspecified whether the prompt is shown, depending on whether the first or second event fires first. Is _that_ Boris's problem? Please explain if I'm mistaken.

I'm trying to figure out how serious a problem this is. On the one hand, it's trivial for a web developer to write code that depends on the delivery order of events (and we can't do anything about that). But if we really want to solve this, we could make it so that prompt() is always successful: the very existence of the BeforeInstallPromptEvent means that the UA has permitted an install prompt, so you should always be able to show the prompt if you have such an object. Calling preventDefault() on the event _after_ calling prompt() would have no effect. Therefore, as prompt() always trumps preventDefault(), if you call both you will see the prompt regardless of the order. Does that satisfy your concerns?

On the other hand, I do find there something a bit weird about using the event object itself to do this (potentially having to store the event object around for a long time until you are ready to prompt it). Some more approaches (backwards-incompatible with the current Chrome implementation):

  • A global method like navigator.showInstallPrompt() that lets you explicitly ask to show an install prompt, which will succeed if you've been given a BIP event and haven't yet prompted, but fails otherwise.
  • An object on the event has the prompt method (rather than the event object itself). That way you can store that object without having to store the whole event object.

I hope we can get beforeinstallprompt into the spec as it provides real value and we don't want it to just sit around as a Chrome-only thing.

I agree that the beforeinstallprompt is quite useful. Showing the prompt at the right time can really make a difference (watch the presentation by booking.com at the PWA summit: https://youtu.be/LUwdZfEW0C4?t=9m58s). It can also make the install prompt less annoying, like the way Flipkart is doing it.

Like @mgiuca I also didn't fully understand the complains.

I think the problem with navigator.showInstallPrompt() is that way you don't necessarily know whether it will succeed or not, which means that you cannot adopt your UI in the way Flipkart does it. Of course, we could add another method for querying that, or people can set some state when receiving the beforeinstallevent event, but this way it might be a bit more disconnected than the solution currently in Chrome.

Yep I agree with Ken's analysis on showInstallPrompt. (Just trying to throw out some alternatives to make sure we aren't going with the current status quo just because it's already implemented.)

I think the problem is that another script can block you from ever seeing the prompt in an undeterminable way. Having a separate method is in some ways worse, as a third party script might try calling the show method at a point the host site doesn't intent, even if it wasn't the same script calling preventDefault().

Are you saying there's a problem with a malicious third-party JavaScript running in your site's JS context (capturing the install event, calling preventDefault, and never prompting)? I think if you're letting untrustworthy JS code run on your site (due to XSS presumably) then it's game over already; we can't design around that scenario.

It looks like install is going ahead and it's relatively simple and non-controversial, so I've started implementation in Chrome (I just sent an Intent to Implement email today; Chrome tracking bug).

🎉

I don't think install satisfies this requiement. We want a way to report to the website before showing the install prompt, to give the site an opportunity to suppress the banner and expose it to the user at a more convenient time (e.g., instead of it popping up in the user's face, it can provide a banner/button to install.

We are all in 100% agreement with the above - and that "install" is a separate use case. We don't need to discuss it here as it's solved.

Now in this particular case, it's unspecified whether the prompt is shown, depending on whether the first or second event fires first. Is that Boris's problem?

I think it's actually the opposite - (but hopefully @bzbarsky can comment here). I think it's the opposite:

//  Script 1 - totally show it! (<script async>) 
window.addEventListener('beforeinstallprompt', e => e.prompt());
// Script 2 - whoa! don't show it! (<script async>)
window.addEventListener('beforeinstallprompt', e => e.preventDefault());

Calling preventDefault() on the event after calling prompt() would have no effect.

Ok, but this might be where you end up getting into funky race conditions depending on ordering of in which scripts are executed (using script@async): event order registration is not deterministic.

  • A global method like navigator.showInstallPrompt() that lets you explicitly ask to show an install prompt, which will succeed if you've been given a BIP event and haven't yet prompted, but fails otherwise.

I kinda like this. I also don't like the idea of having to hang onto random objects from the event.

Are we sure we can't do a flipkart here?

var wasPrompted = false; 
addEventListener("beforeinstallprompt", e => {
    e.defaultPrevented();
    wasPrompted = true;
    icon.shakeThatCutePhoneIcon();
});

icon.onclick = (e) => {
   if(icon.isShaking && wasPrompted){
      navigator.showInstallPrompt();
   }
}

A little out of left field, but we might even consider if there is overlap with the permissions API?

navigator.permissions.query({name:'install'}).then(function(result) {
   if (result.state == 'prompt') {
     navigator.permissions.request({name:'install'}).then(...)      
  }
});

I hope we can get beforeinstallprompt into the spec as it provides real value and we don't want it to just sit around as a Chrome-only thing.

Us too! I've emailed Boris and Olli Pettay (who Gecko's events model probably better than anyone). They are the ones that will need to approve if this goes into Gecko, so I'm hoping they will have good feedback.

I think it's the opposite:

window.addEventListener('beforeinstallprompt', e => e.prompt());
window.addEventListener('beforeinstallprompt', e => e.preventDefault());

That's the same as my example with the two registrations swapped around, right? My assumption was that the registration order doesn't matter (so this is the same as my example)... either way, the point is that as it stands now (in Chrome), it will either prompt or not prompt depending on the order the events get delivered (which may or may not be the same as the declaration order).

The question is whether it's acceptable to have a web API that behaves differently depending on event order. It doesn't seem too horrible given that I can easily write some code like this:

var pressed = False;
window.addEventListener('keydown', e => { pressed = true; });
window.addEventListener('keydown', e => {
  if (pressed)
    alert('You pressed the key');
});

Whether the alert is shown depends on the execution order of the listeners, which is unspecified. Any stateful API can be made to behave differently depending on event listener evaluation order. So I'm not really seeing the big deal of adding a new API whose behaviour can be made to depend on event listener evaluation order (really, it just means the API is stateful).

Anyway, assuming we don't want the API to be abuseable in this way...

Ok, but this might be where you end up getting into funky race conditions depending on ordering of in which scripts are executed (using script@async): event order registration is not deterministic.

My suggestion was to make prompt show the prompt regardless of whether preventDefault had been called, and conversely, make preventDefault _not_ prevent the prompt if prompt has been explicitly called. This should mean in the above example (regardless of which order the listeners are registered or which order the UA decides to execute the listeners in) it behaves the same way.

  • If prompt goes before preventDefault, it shows the prompt and preventDefault has no effect.
  • If preventDefault goes before prompt, the automatic prompt is cancelled, but then prompt causes the manual prompt to be displayed.

So either way, the user will see the prompt.

Are we sure we can't do a flipkart here?

Your example captures the way I imagined a hypothetical navigator.showInstallPrompt to work. However, note that this API by itself doesn't solve the unspecified order problem:

window.addEventListener('beforeinstallprompt', e => e.preventDefault());
window.addEventListener('beforeinstallprompt', e => navigator.showInstallPrompt());

You have the same problem. So the question of whether to make BeforeInstallPromptEvent.prompt() (or navigator.showInstallPrompt) not require preventDefault is orthogonal to the question of whether it should be a method on BeforeInstallPromptEvent or navigator.

A little out of left field, but we might even consider if there is overlap with the permissions API?

I'm not really a fan of this approach because it implies the user would be prompted right away and/or that the page has to continuously poll to see whether the permission is available. I'd rather it be delivered as an event.

FWIW, I think the aspect of onbeforeinstall that allows a site to hold the event until later to use the prompt method to be very unusual. If we could normalize that part to an API that can be called once a permission is granted, that would be great.

I'm not really a fan of this approach because it implies the user would be prompted right away and/or that the page has to continuously poll to see whether the permission is available. I'd rather it be delivered as an event.

sorry, I should have clarified the proposal.

The BIP would still be fired - but it would be a simple Event that is cancellable. It does not imply that or require to be prompted right away: the UA is still in control of the prompting, in coordination with the page.

The page would not be required to continuously poll because the Permission API notifies of changes to permission states (or else the Permission API itself would be completely broken - because then everyone would need to poll for every permission type).

The installation model currently proposed replicates much of the Permissions API's model, and I'm starting to think we should give serious consideration about retrofitting the current installation model to fit.

The only difference is that installation's initial state is "denied", then switches to "prompt" if BIP's default is prevented. And "granted" does nothing, as its been installed.

FWIW, I think the aspect of onbeforeinstall that allows a site to hold the event until later to use the prompt method to be very unusual.

Agree. If with using the Permission API, you could hold the "prompt" state without needing to listen for BIP first. And BIP wouldn't need to constantly fired. It could be fired only once - and subsequent navigations could simply check the permission state.

I'm biased... but I'm starting to like the permission thing more and more :)

@mounirlamouri would really like your input here, as you know the Permissions API better than anyone.

What would 'granted' or 'denied' mean in response to navigator.permissions.query?

If 'granted' == 'already installed' and 'denied' means 'not installed' it feels like a weird conceptual fit to permissions.

If 'granted' == 'already installed'

What about something like:

  • "granted" would mean: "An install attempt was made by showing the dialog. If it succeeded - you should have gotten oninstall event... if you missed it, your fault!" (unless we add oninstallerror, down the road). But it has nothing to do with installation success/fail.
  • 'denied' means "I asked the user, but they said go away stop bugging them!" or "I'm not ready to bug the user yet for you... wait a while or add more PWA stuff to meet the criteria".
  • 'prompt' means: "you previously prevented the default action, but you are free to ask again now".

Hi there. I just had a long chat with @dominickng and @benfredwells about this. We have some mixed thoughts.

Firstly, let's start with what I proposed above (a global method rather than a prompt method on the BeforeInstallPromptEvent object). That is:

  • We keep beforeinstallprompt event, but remove the prompt method from the event. (It keeps its preventDefault behaviour.)
  • We add a new global method to initiate installation. Instead of calling it navigator.showInstallPrompt, let's call it navigator.installApp or similar.

OK, let's call that the _base proposal_.

Now you're suggesting we add (essentially) two new things:

  1. permissions.query({name: 'install'}), to query whether it has been installed, can't yet be installed, or can be installed at the user's discretion, and
  2. permissions.onchange, to be notified when that state changes.

Let's call that the _permissions proposal_. Notably, it doesn't (and can't) remove anything from the base proposal. In theory, we could drop the BIP event and just use the change event, but then that would mean you call preventDefault on the change event object, and that sounds totally wrong, so we must keep BIP.

What does permissions add?

So what do those two new capabilities buy us? At first glance (for Chrome's current policy), not much:

  1. query essentially tells us whether the install has succeeded ("granted"), BIP has been triggered ("prompt") or nothing has been allowed yet ("denied"). But we can already tell whether it's been installed, and whether BIP has been triggered (just store a boolean variable in response to the install and beforeinstallprompt events, respectively). So this adds nothing.
  2. change tells us when that state changes. But again, the BIP and install events already tell us when the user is being prompted and when the app is being installed.

So why do we want this?

The main reason we can think of is the separate BIP and change events allows UAs to have some interesting different policy options:

  • The "Chrome" policy: "denied" by default. As soon as user engagement threshold is reached, we prompt. We fire the BIP and change (to "prompt") events at the same time. The site can either let the BIP go ahead, or cancel it and use the prompt powers later.
  • An "always allow" policy: "prompt" by default. Apps can install themselves whenever they want, but there is no automatic prompt. BIP is never fired. The website can query, find the "prompt" status, and show a prompt at its leisure.
  • A "silent transition" policy: "denied" by default. As soon as user engagement threshold is reached, the change event is fired to transition to the "prompt" state. BIP is never fired, and no prompt is ever automatically shown. The site can automatically prompt upon transition, or hold off until the user requests.
  • A mixed policy: after some low engagement threshold, a silent transition to "prompt" (firing a "change" event but not a BIP). After a higher engagement threshold, an automatic prompt and BIP are fired.

The user agents can decide on the above policies and change policies without having to update the spec. So that's kind of cool.

Some other pros:

  • No need to store your own global on BIP; can just use permissions.query() to figure out whether you're allowed to attempt to install.
  • Kinda fits the permissions model. It helped us think about naming the method "install" rather than "showInstallPrompt" (just as we have "getUserMedia" not "showGetUserMediaPrompt", even though that will likely trigger a prompt as well).
  • We may not need an install event at all (the permissions.change event to "granted" may be sufficient, but see below).

The problems

The main problem is with what "granted" means. You said:

"An install attempt was made by showing the dialog. If it succeeded - you should have gotten oninstall event... if you missed it, your fault!" (unless we add oninstallerror, down the road). But it has nothing to do with installation success/fail.

The problem with this is that "granted" is normally an ongoing state. It means you are free to use, say, the microphone or geolocation, without restriction. In the install case, it _should_ mean you are free to repeatedly install the app whenever you like (which we don't want to allow).

What it will actually mean in practice is "the app has been installed, and you cannot request it again". Since installation is an action (not a state), the "granted" state will be functionally equivalent to the "denied" state. What happens if you call navigator.installApp() from the "granted" state? Does it succeed silently (actually doing nothing)? Does it attempt to re-install the app? (On Android at least, that creates duplicate home screen icons, and Chrome is unable to detect whether one already exists.) Does it reject the promise? (Then in what way is it "granted"?) What if installation fails? Do we still go into a "granted" state which means "you failed to install and cannot try again, sorry"? There are lots of conceptual problems with "granted" meaning what you said it means.

Furthermore, what does permissions.request mean for the "install" permission? It's supposed to prompt the user then if they say yes, go into the "granted" state. What does this mean? It wouldn't actually install anything, you'd be in the granted state. Does that mean the app is free to install itself at some point in the future? (That isn't good.) Or is it just a no-op? This is where we think installation doesn't really match up with the permissions system: the permissions system is about granting perpetual access to a resource, not taking a single action once.

Basically, we could write into the spec answers to all of these questions that make it behave the way we want, but it would then feel like it doesn't fit into the permissions system properly.

Some more negatives:

  • It makes things more complicated for UA developers and web developers (given that it's a superset of the base proposal).

    • @benfredwells says it will be very awkward to put it in as a permission internally in Chrome.

    • Getting _both_ a BIP and change event at the same time could be confusing for developers.

  • If we allow different UAs to behave differently (as I outlined above), it means web developers need to correctly accomodate all of those different modes, which could lead to browser-specific bugs on certain sites.
  • @dominickng says the permissions API itself is still in flux.

In closing

OK, so I think we first need to decide whether the pros I listed above are actually desirable. If not, then I don't see a reason to attach this to the permissions system. If they are, then we have two choices: we can extend the beforeinstallprompt APIs to provide those benefits without using the permissions API, _or_ we can go ahead and use the permissions APIs, after we come up with some good answers for what "granted" and request mean.

Would it make sense to just not use the "granted" state? ie only "denied" and "prompt".

What happens when a user removed the app?

Would it make sense to just not use the "granted" state? ie only "denied" and "prompt".

We thought about not using the "granted" state at all (after installation you just go back to "denied"). This would mean that:

  • When the user clicks "install", we transition into the "denied" state. Seems a little strange.
  • We still have problems with request(). Since request is supposed to prompt the user and if they say "OK" go into the "granted" state, what do we do? I guess it would have to be a no-op. My quick reading of the permissions spec suggests this is allowed (you can define your own permission granting algorithm which can just be a no-op), but again, doesn't really fit the spirit of the permissions model.
  • We still need a separate "install" event.

Basically, if we got rid of the "granted" state, then I think it would work (in fact, I think that's the only way this would work). But in doing so, I think it implies that the permissions model is the wrong model (if you have a permission that can never be granted, then why is it a permission?)

What happens when a user removed the app?

At the moment, Chrome can't detect this (in fact I think Android doesn't give us a way to know). So we can't react to it. But I think it works out mostly OK, because if the user removes an app, we shouldn't be re-prompting them. They installed your app and deleted it; you've had your chance. If they want it back, they can manually add to home screen from the menu.

Yes, it feels to me that it is not the right fit. I believe that developers will think it is a permission to prompt or to install, but you are not going to allow further installs, or disallow manual installations when denied.

Potentially when the WebAPK code lands, can't you query on android whether it is installed or not?

As far as I'm aware, we can query if a WebAPK is installed, but they won't be used for all types of installations, so we wouldn't be able to consistently check.

Thanks for the detailed write up and thoughts... some reactions below. I think we should have a call and walk through everything in detail. I'll try to gather more feedback from folks internally also. If need be, I'm happy to fly out to Sydney for a day so we can go through this in detail (I'm in Melbourne, so no big deal for me).

permissions.query({name: 'install'}), to query whether it has been installed, can't yet be installed, or can be installed at the user's discretion, and

The installation itself is orthogonal: The permissions.query({name: 'install'}) is an inquiry about the ability for the app to show the installation prompt. A successful install is still signaled by "oninstall" in my proposal. This is to cover the use case where installation is self-initiated by the end user (by digging through the menus or through an "ambient installation").

Notably, it doesn't (and can't) remove anything from the base proposal.

It's unclear why it can't remove anything from the base proposal? It makes the base proposal redundant, for reasons you also state (but with caveats I questioned below):

  1. BIP is equivalent to permission.onchange.
  2. nav.installApp() is permissions.request({name: install}) when prompt state is available.

In the case of BIP and having a cancellable onchange event, that strikes me as architectural purity: is there a technical reason to not allow the event to be cancellable? That it "sounds totally wrong" needs further consideration, IMHO: It might be that in whatever we come up with, the UA never shows the install prompt automatically (allows for "ambient install"), and only ever allows the the developer to permission.request({install}).

The above solves the problem in that BIP is not needed. onchange does not need to be cancellable, and gives control of the install prompt back to the developer - with the UA providing "ambient install" as a fallback.

So what do those two new capabilities buy us?

I think this makes an incorrect presupposition: it presumes that because Chrome already has BIP and prompt(), that other implementations have this also (which they don't). So, when you say "us", it means "Chrome" in this case. In the case of Gecko, for instance, we already have the Permissions API - so what it buys "us" (Gecko) is one less API to add.

I guess my point is: we can't take the current BIP (Chrome) model as a given - as we've seen, there is plenty of scope for improvements from what we've learned over the last years.

"silent transition"

I'm advocating for "silent transition". I think it strikes the right balance between UA, developer, and end-user control.

The user agents can decide on the above policies and change policies without having to update the spec. So that's kind of cool.

Agree. We still have a time to refine, experiment, etc. if we end up with a mixed policy.

We may not need an install event at all (the permissions.change event to "granted" may be sufficient, but see below).... There are lots of conceptual problems with "granted" meaning what you said it means.

Agree. "granted" remains highly ambiguous because there is a difference between "the installation process finished successfully (and a icon was placed somewhere successfully)", or "you were granted permission to install (which may or may not have succeeded)", or "the application is, and remains, 'installed' (for which the icon still available somewhere, maybe)".

To me, "granted" can only mean "you were granted permission to install"... but if that succeeded, or if the icon remains on the home screen, is not in scope and is a different concern. The UA allowing multiple icons on the homescreen from mismanagement would likely be a bug.

the permissions system is about granting perpetual access to a resource, not taking a single action once.

I concede you may be right here. But I need to give this further consideration. However, this predicated on how we frame "granted" - and if it's only conceptual, then it might be workable to just treat it as a no-op.

@dominickng says the permissions API itself is still in flux.

This is true in as far that all specs are in flux, only because we shape them with our requirements :)

We still have problems with request(). Since request is supposed to prompt the user and if they say "OK" go into the "granted" state, what do we do? I guess it would have to be a no-op.

Request is not required to do such a thing (or at least it should not be). If the request has been granted, then yes: it's a no-op. Once you are granted, you are granted. Only "prompt" allows a developer to put the a prompt in front of the end-user.

Yes, let's do a call, these have been quite productive in the past. I can participate this week, but then I am off on vacation.

OK let's do a call this week. I will email Marcos and Ken to organize offline. If anybody else watching this thread wants to participate, please PM me.

Since we're hopefully meeting today, I won't reply to Marcos point-by-point. But I think we need to step back from the specific details of the API (using permissions or the details of onbeforeinstallprompt) and figure out what the behaviour should be.

Specifically, the main dispute here seems to be whether we should
a) allow auto-prompt, giving the site an opportunity to suppress it and show it at a later time of its choosing (prompt-by-default), or
b) disallow auto-prompt, but allow the UA to notify the site that it can show the prompt at a time of its choosing (prompt-on-demand).

It seems reasonable to allow UAs to choose between prompt-by-default or prompt-on-demand behaviour (or, of course, to never allow a prompt and only A2HS when the user explicitly asks for it), and we should work towards a spec that permits the UA to do both.

My primary concern is developers needing to write special logic to handle different browsers (or, by corollary, only writing logic for one browser and breaking on other browsers). So I'd like to find a solution that a) allows Chrome to prompt automatically but let developers cancel the prompt, without forcing other browsers to do the same, and b) doesn't require developers to behave differently on browsers that use prompt-by-default or prompt-by-demand. Hopefully those are agreeable goals and we can discuss ways to achieve that tonight.

Ok, so we are going to try to standardize on Google's implementation. It's been shipping for a while, it's supported in Opera, and Samsung's browser.

Going to send it in parts.

Thanks @marcoscaceres, @kenchris should be happy to review the PR from our side.

Started WIP patch here: https://github.com/w3c/manifest/pull/506

It's way too drafty to comment on still... need to rework the whole install section, but let's get the API in place at least.

Trying to do the implementation at the same time in Gecko. Will also send that in parts. I have window.onbeforeinstallprompt implemented thus far... but not the actual BeforeInstallPrompt event itself.

Ok, so #506 revealed a couple of issues that we could address. This aim is to minimize impact on existing developer code... but the following would be a breaking change.

I'm wondering, can we make the design work more like this:

window.onbeforeinstallprompt = async function ( e )  {
  if (!userIsDoingThings) {
    return; // automatically show it.  
  }
  e.preventDefault(); 
  await Promise.all(thingsTheUserIsDoingThatBlockInstall);
  const promptResult = await ev.prompt();
  switch (promptResult.userChoice) {
    case "dismissed":
      // The user didn't want the app 😢
      analytics({ userHatesUs: true });
      break;
    case "accepted":
      // Awesome! it's installing 
      analytics({ userHatesUs: false });
      break;
    default:
      console.error(`Unknown choice? ${promptResult.userChoice}`);
  }
}

window.oninstall = e => {
  // The application installed!
}

Key things that could change:

  1. get rid of .userChoice attribute on the event.
  2. define a new interface PromptResult that contains the user choice and maybe other stuff.

There's a lot to unpack there. To specifically enumerate what you're proposing (from my reading of the example):

  1. prompt() resolves only when the user makes a choice (whereas currently it resolves immediately).
  2. prompt() returns the user's choice directly, so there is no need to request userChoice on the event (whereas currently it returns void).

I much prefer this. I only realised while reviewing your patch that propmpt resolves immediately and I thought that was weird.

Note that we still need userChoice on the event, because it is used to tell us about the user's choice when the banner is automatically shown. In your above example, you aren't recording analytics in the early return case. What you need there is:

window.onbeforeinstallprompt = async function ( e )  {
  var choice;
  if (!userIsDoingThings) {
    choice = await e.userChoice; // automatically show it.  
  } else {
    e.preventDefault(); 
    await Promise.all(thingsTheUserIsDoingThatBlockInstall);
    const promptResult = await ev.prompt();
    choice = promptResult.userChoice;
  }
  switch (choice) {
    case "dismissed":
      // The user didn't want the app 😢
      analytics({ userHatesUs: true });
      break;
    case "accepted":
      // Awesome! it's installing 
      analytics({ userHatesUs: false });
      break;
    default:
      console.error(`Unknown choice? ${choice}`);
  }
}

You could use appinstalled to figure out when the user accepts the automatic prompt, but there is no signal to tell you when the user has cancelled it, other than the rejection of BeforeInstallPromptEvent.userChoice.

Whether we want to make breaking changes is something we need data for and I don't personally feel comfortable making that decision (would want to speak with Alex again).

Note that we still need userChoice on the event, because it is used to tell us about the user's choice when the banner is automatically shown.

Good point. If prompt becomes a sync call, then it simplifies things to:

window.onbeforeinstallprompt = async function(e) {
  if (thingsTheUserIsDoingThatBlockInstall.length) {
    e.preventDefault();
    await Promise.all(thingsTheUserIsDoingThatBlockInstall);
    e.prompt();
  }
  const { userChoice: choice } = await e.userChoice;
  switch (choice) {
    case "dismissed":
      // The user didn't want the app 😢
      analytics({ userHatesUs: true });
      break;
    case "accepted":
      // Awesome! it's installing
      analytics({ userHatesUs: false });
      break;
    default:
      console.error(`Unknown choice? ${choice}`);
  }
};

You could use appinstalled to figure out when the user accepts the automatic prompt, but there is no signal to tell you when the user has cancelled it, other than the rejection of BeforeInstallPromptEvent.userChoice.

I don't think that dismiss would be a rejection, as it's not "exceptional" for the user to dismiss the install. It just resolves to "dismissed".

The thing that still bothers me about the overall design is that .onappinstall essentially equates to ev.userChoice = "accepted" - hence user choice is not really needed. I'm a little uncomfortable that we are revealing actual background processing details in the current design. For instance:

  1. how long the UA takes to present the install prompt: time between prompt() called and prompt() promise resolved - and system specific errors in the form of the rejection (which are probably none of the app's business that I can think of).
  2. how long it takes for a user to make a choice: time it takes for await e.userChoice resolving.
  3. how long it takes to write to disk. Time between ev.userChoice resolved and .onappinstalled called.

Making .prompt() synchronous and relying on .onappinstalled masks some of the above, because all the app can know is how long did it take from .prompt() to the app actually being installed - but none of the details of the process.

The thing that still bothers me about the overall design is that .onappinstall essentially equates to ev.userChoice = "accepted" - hence user choice is not really needed. I'm a little uncomfortable that we are revealing actual background processing details in the current design.

.onappinstall isn't necessarily the same as ev.userChoice = "accepted". The user could accept the banner, but the app might fail to install for a bunch of reasons. WebAPKs are a good example of this, as is the bookmark app system which Chrome uses on desktop.

I hazily remember initially speccing prompt() as a synchronous method returning a bool, but it was suggested to make it a promise to allow UAs more flexibility in case they needed to do async work in the method. I don't mind overly here (changing Chrome's implementation to return a bool is probably fine since I don't think many people at all actually use the promise returned by prompt). But retaining the flexibility in the API does seem like a good thing (naively).

Is there any reason to have prompt return a value at all? Can it just be a void method (no Promise)?

If it isn't going to be a long-Promise (resolve when the user makes a choice), then there is really no reason for the app to do anything in response to its resolution. It doesn't have to be synchronous (i.e., the prompt can be shown after a message loop). But it could be a fire-and-forget, with no need for the user to wait on it resolving when the dialog is shown. Then if you want to wait for the user's choice you use userChoice.

I think it either makes sense for prompt to be a long promise or fire-and-forget. The middle-ground we currently have doesn't make sense.

.onappinstall isn't necessarily the same as ev.userChoice = "accepted". The user could accept the banner, but the app might fail to install for a bunch of reasons. WebAPKs are a good example of this, as is the bookmark app system which Chrome uses on desktop.

I completely agree, but the fundamental question is: does the app need to know about these (system) failures? If so, what is the use case for them knowing? There is not much the application can do to recover.

If notifying the application of installation does have a valid use case, then we need to also handle the case where BIP is not involved... so we would need a .onappinstallerror or some such event handler.

I hazily remember initially speccing prompt() as a synchronous method returning a bool, but it was suggested to make it a promise to allow UAs more flexibility in case they needed to do async work in the method.

The return value is orthogonal to any asynchronous work. In Gecko, the work (of requesting some king of install overlay) will definitely need to be done asynchronously.

But the question about if the application has any business/use-case about knowing these details is what I'm questioning.

I don't mind overly here (changing Chrome's implementation to return a bool is probably fine since I don't think many people at all actually use the promise returned by prompt). But retaining the flexibility in the API does seem like a good thing (naively).

It depends on what the return value reveals - and the use case for that information.

Is there any reason to have prompt return a value at all? Can it just be a void method (no Promise)?

I can't see why not.

If it isn't going to be a long-Promise (resolve when the user makes a choice), then there is really no reason for the app to do anything in response to its resolution. It doesn't have to be synchronous (i.e., the prompt can be shown after a message loop). But it could be a fire-and-forget, with no need for the user to wait on it resolving when the dialog is shown. Then if you want to wait for the user's choice you use userChoice.

Exactly. I would feel more comfortable with this.

I think it either makes sense for prompt to be a long promise or fire-and-forget. The middle-ground we currently have doesn't make sense.

Agree. Making it fire and forget would be better.

I got some data from Chrome (Android, stable version 53) about beforeinstallprompt which I can share here:

  • 0.026% of page loads get the beforeinstallprompt event (but note that this does not necessarily mean they have a listener registered).
  • 19% of beforeinstallprompt events have preventDefault called.

    • That's surprisingly high.

    • Still, that's just 0.005% of page loads with BeforeInstallPrompt.preventDefault called.

Unfortunately, we do not have data about access to userChoice. Nor do we have data about the number of beforeinstallprompt events that are actually handled by a listener.

All of this is below the unofficial Blink deprecation threshold of 0.03% so we could potentially make breaking changes to this API if the need arises.

Ok, let's work quickly to fix some of this before those numbers go up.

If possible, can we start by making "BIP" just a normal event and relocate .prompt() somewhere else?

By normal event, I mean a "simple event" that just uses the Event interface, but is cancellable.

*uses

So, there is a case where .prompt() can enter into an invalid state :(.

  1. BIP is fired.
  2. app calls .preventDefault();
  3. app begins awaiting install blocking tasks to finish.
  4. End-user chooses "add to homescreen" from browser's menu.
  5. app calls .prompt()
  6. IPC message sent to UI thread - browser discovers "install process in progress"
  7. ??? Invalid state ??? .userChoice can't ever resolve.

To fix this, .prompt() only resolves when given ownership of an install process - blocking manual installs for this app.

For the public record: between your second-most and most recent messages, we had an offline discussion where we concluded that we'd try to change prompt() to a fire-and-forget (non-promise-based) interface, but that we wouldn't move it out of the BeforeInstallPromptEvent.

Responding to your most recent message: I spoke to Dom about this. He says that, in Chrome at least, the user-triggered add to homescreen and the automated (or site-controlled) install banner are independent. Even when there is no race condition, they are still independent and using one does not preclude the other. Take the following non-racey case:

  1. BIP is fired.
  2. App calls .preventDefault().
  3. App sets up a then on the .userChoice promise.
  4. User chooses "add to homescreen" from browser's menu, and completes the install process.
  5. App calls .prompt.

This is like your use case but the installation fully completes before Step 5.

In this case, the prompt is shown (despite the app already being installed) and the user can choose OK, which installs the app a second time. userChoice resolves once the user chooses on the prompt.

So the answer is just that the two are independent. The app should be installed twice (the second install may be a no-op depending on the system; on Android it would actually create a second shortcut).

As for the race case you outlined, well the system just has to deal with this; for example queueing up the prompt behind the existing install process. I don't think this is an invalid state.

As for the race case you outlined, well the system just has to deal with this; for example queueing up the prompt behind the existing install process. I don't think this is an invalid state.

I hadn't thought about queue'ing it. Although that's not great UX (user might ask, "Didn't I just install this?"), that's definitely a sensible solution.

I think the chances of it happening are so remote that as long as we do something sensible, it doesn't necessarily have to be "great UX".

Furthermore, if we just queue it up, then it's indistinguishable (to the user) from the site simply deciding to call prompt() 100ms after they installed the app. Which is essentially just bad timing.

I think most sites using this will be calling prompt() in response to a user action anyway, not on a timer. The main use case for this is to create a button in your site that the user can click to activate installation. So then the race condition is not an issue.

@mgiuca, @dominickng, do you have an opinion on the following situation:

window.addeventlistener("beforeinstallprompt", (ev) => {
  // Calling without .preventDefault throws.
  // But is it still possible to call .prompt() after?
  try {
    ev.prompt();
  }catch(err){}
  ev.preventDefault();
  ev.prompt(); // Should this succeed?
  ev.prompt();  // this will now throw
});

That is, should we prevent calling .prompt() twice only after 1 successful call?

Updated example code above.

I think it should throw. Calling prompt should essentially un-set the preventDefault bit.

Ok, will make it so you can only call it once - no matter what. I won't change the default prevented tho, but I have an internal flag that gets set.

Ack.

Ok, this is what is going in to the spec (as prose and IDL, obvs.). I've got a battery of about 100 test to go with it.

All, please review the final design.

"use strict";
const internalSlots = new WeakMap();
const installProcesses = [];
const AppBannerPromptOutcome = new Set([
  "accepted",
  "dismissed",
]);

class BeforeInstallPromptEvent extends Event {
  constructor(typeArg, eventInit) {
    // WebIDL Guard. Wont be in spec in spec as it's all handled by WebIDL.
    if (arguments.length === 0) {
      throw new TypeError("Not enough arguments. Expected at least 1.");
    }
    const initType = typeof eventInit;
    if (arguments.length === 2 && initType !== "undefined" && initType !== "object") {
      throw new TypeError("Value can't be converted to a dictionary.");
    }
    super(typeArg, Object.assign({ cancelable: true }, eventInit));

    if (eventInit && typeof eventInit.userChoice !== "undefined" && !AppBannerPromptOutcome.has(String(eventInit.userChoice))) {
      const msg = `The provided value '${eventInit.userChoice}' is not a valid` +
        "enum value of type AppBannerPromptOutcome.";
      throw new TypeError(msg);
    }
    // End WebIDL guard.

    const internal = {
      didPrompt: false,
    };

    internal.userChoicePromise = new Promise((resolve) => {
      internal.userChoiceHandlers = {
        resolve,
      };
      if (eventInit && "userChoice" in eventInit) {
        resolve(eventInit.userChoice);
      }
    });
    internalSlots.set(this, internal);
  }
  prompt() {
    if (internalSlots.get(this).didPrompt) {
      const msg = ".prompt() can only be called once.";
      throw new DOMException(msg, "InvalidStateError");
    } else {
      internalSlots.get(this).didPrompt = true;
    }

    if (this.isTrusted === false) {
      const msg = "Untrusted events can't call prompt().";
      throw new DOMException(msg, "NotAllowedError");
    }

    if (this.defaultPrevented === false) {
      const msg = ".prompt() needs to be called after .preventDefault()";
      throw new DOMException(msg, "InvalidStateError");
    }

    (async function task() {
      const userChoice = await showInstallPrompt();
      internalSlots.get(this).userChoiceHandlers.resolve(userChoice);
    }.bind(this)())
  }

  get userChoice() {
    return internalSlots.get(this).userChoicePromise;
  }
}

// Browser behavior for firing the event
async function notifyBeforeInstallPrompt(element) {
  await trackReadyState(); // don't fire until document fully loaded!
  if (installProcesses.length) { // If the user is already installing, stop
    return;
  }
  const event = new BeforeInstallPromptEvent("beforeinstallprompt");
  window.dispatchEvent(event);
  if (!event.defaultPrevented) {
    await showInstallPrompt(element);
  }
}

Wow, OK. I will have a proper look at that tomorrow. Thanks!

@mgiuca, I've added the above to https://github.com/w3c/manifest/pull/506 - so it's easier to review. Also updated the variable names to match your suggestions from the PR (i.e., "promptOutcome" instead, etc.).

If anyone wants to play with it (Chrome Canary only, as it uses async/await):
https://rawgit.com/w3c/manifest/beforeinstallprompt/implementation/index.html

Ok, spec text now matches reference implementation. However, I'm still bothered that ev.userChoice can be left unresolve in case of an error.

We have two options:

  1. We add a new enum value to AppBannerPromptOutcome, like "unknown".
  2. Or, maybe more correct, we call reject() on the [[\userChoice]] promise.

My initial thought is that rejecting the userChoice promise is the right way to go here. What particular error conditions were you thinking of for this? General issues installing the app once there is a definitive signal to proceed to installation?

@dominickng, I think the rejections would only happen as a result of a bad call to prompt(). So I'm thinking of rejecting with the errors generated there.

Ah yes, that makes complete sense. I agree that if prompt() is rejected, then userChoice should reject as well.

@dominickng, prompt() now basically looks like this:

  prompt() {
    let error = null;

    if (internalSlots.get(this).didPrompt) {
      const msg = ".prompt() can only be called once.";
      error = new DOMException(msg, "InvalidStateError");
    } else if (this.isTrusted === false) {
      const msg = "Untrusted events can't call prompt().";
      error = new DOMException(msg, "NotAllowedError");
    } else if (this.defaultPrevented === false) {
      const msg = ".prompt() needs to be called after .preventDefault()";
      error = new DOMException(msg, "InvalidStateError");
    }

    if (!internalSlots.get(this).didPrompt) {
      internalSlots.get(this).didPrompt = true;
    }

    if (error) {
      internalSlots.get(this).userChoiceHandlers.reject(error);
      throw error;
    }
    // Show the prompt... 
 }

Yes, that seems good to me. @mgiuca, did you have any thoughts?

Made a fix to the code above. I changed last else in the chain to the following, instead:

    if (!internalSlots.get(this).didPrompt) {
      internalSlots.get(this).didPrompt = true;
    }

I think I prefer the old behaviour -- userChoice would be left unresolved in the case of a prompt error.

Leaving promises unresolved isn't a bad thing: it just means that the conditions to resolve them weren't met. I don't see a strong relation between a prompt error and userChoice; in fact you can still successfully make a choice after a prompt error. For example:

window.addEventListener('beforeinstallprompt', () => {
  try {
    e.prompt();  // Error: preventDefault not called
  } catch (e) {}
  e.preventDefault();
  e.prompt();  // This is fine.
});

It would be bad to reject userChoice on the first prompt, because it's still meaningful later.

In fact, just this:

window.addEventListener('beforeinstallprompt', () => {
  e.prompt();  // Error: preventDefault not called
});

It's going to continue with the automatic prompt after that. userChoice should not be rejected by the call to prompt().

@mgiuca, d'oh, I think then we had a miscommunication back in https://github.com/w3c/manifest/issues/417#issuecomment-254726542

I thought we had agreed that .prompt() can only be called once. If prompt() can be called more than once, then I agree that the userChoice promise cannot be resolved.

So, should we agree that this is ok:

window.addEventListener('beforeinstallprompt', () => {
  try {
    e.prompt();  // Error: preventDefault not called
  } catch (e) {}
  e.preventDefault();
  e.prompt();  // This is fine.
});

I meant that prompt can only be called once successfully. Calling prompt with an error should not set the didPrompt flag, IMHO.

If you do want to make it so calling it in error prevents you from calling it again (I won't strongly object), then I still think my second example above holds: if you call prompt in error, but then don't call preventDefault, you will still get a prompt, so userChoice can still resolve. Ergo, calling prompt in error should not reject userChoice.

@mgiuca, I agree. I'll make some modification to match the above.

@mgiuca, I think then I need to turn didPrompt into a little state machine. The you can call prompt() as many times as you like after .preventDefault() - and those calls will silently be ignored:

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:

if (didPrompt) {
  error "prompt has already been shown"
  return;
}

if (defaultPrevented) {
  error "must call preventDefault first"
  return;
}

show prompt
didPrompt = true;

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:

  1. Just because something is a no-op doesn't mean it should throw an error, and
  2. You can get around it anyway, as you pointed out, and
  3. If we add a long-wait promise to tell you what the outcome is, there is actually utility in calling prompt() without preventDefault, because you will be told what the user's choice is. (i.e., it means prompt() subsumes userChoice, and then you literally never need userChoice). This allows us to actually say userChoice is deprecated (though as discussed yesterday, killing it is hard since apparently it has a lot of usage).

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 .userChoice attribute (as the spec is currently intertwined with that) - it's something we would prefer not to implement in Gecko.

Can I get an ok that .userChoice will get deprecated in Chrome?

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 promp()ting more than once at all?

Consider, a custom BIP that has the userChoice provided. This means that the UA can just resolve like this:

// 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 .prompt() as many times as you like. You always get the same outcome.

Now consider a real/trusted BeforeInstallPromptEvent:

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 .prompt()'s promise if the promptOutcome is already known (either ahead of time, as in the case with the user-constructed event, or after the prompt has resulted in a value).

Thoughts?

I see, so you're saying that you can call prompt many times and it will just return a bunch of promises, all of which get resolved at the same time?

The drawback of the above is that we need to keep a queue of promises that are waiting an outcome.

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 prompt() _after_ the dialog choice is made, it should not be a no-op. It should "try" to prompt again and fail because you're only allowed to do that once.

So I'd be happy for:

  1. prompt()
  2. prompt()
  3. Click Install.

To have both promises succeed, but

  1. prompt()
  2. Click Install.
  3. prompt()

to have the second prompt fail. Does that sound OK?

I see, so you're saying that you can call prompt many times and it will just return a bunch of promises, all of which get resolved at the same time?

Yep.

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.

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.

To have both promises succeed, but ... to have the second prompt fail. Does that sound OK?

This one could just resolve with the results of whatever "Click install" was. That would still adhere to only being able to actually prompt() once: you just a promise that immediately resolves to whatever "Click install" resulted with.

This one could just resolve with the results of whatever "Click install" was. That would still adhere to only being able to actually prompt() once: you just a promise that immediately resolves to whatever "Click install" resulted with.

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:

  1. Make BIPE.prompt() callable multiple times (if the UA allows reprompting).
  2. Fire another BIPE immediately after a failed call to prompt() (if the UA wants to allow another prompt).
  3. Change the API entirely.

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 prompt(), isn't that misleading because it isn't actually before an install prompt... the UA has no intention of showing an install prompt even if preventDefault isn't called! So that's another thing we talked about: we want to not mandate that the UA show an install prompt after a non-cancelled BIP. Because we want the freedom to be able to give the site the ability to show a prompt without necessarily showing the prompt automatically if the BIP is not cancelled. And besides, Alex is adamant, specs shouldn't mandate UI.

So in summary:

  • The spec should not mandate that the UA displays a prompt if the BIPE is not cancelled (should be up to the UA).

    • This may require changing the language around "presents an install prompt" to say non-normatively that the UA may show some UI asking the user.

    • Even when prompt() is called, we should not mandate UI. It should non-normatively suggest that that’s what probably should happen.

  • Because a BIPE may or may not indicate that an install prompt is about to happen if you don't cancel it, we could consider adding some attributes that indicate whether or not a prompt would be shown in fallthrough. But we don't need to do this; we could also just say that if you don't want a prompt, call preventDefault, if you do want a prompt, call prompt(). If you do nothing, you get unspecified default behaviour. I am personally in favour of not adding an attribute here.
  • Allow multiple BIPEs to be thrown in a single page load -- for example if the user says No, the UA is allowed to fire the event again (either immediately or after some time passes).

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 navigator.requestInstallPrompt many times even after the user has cancelled the first one? It just seems like bipe.prompt() is going to be redundant, so if we spec both, we might end up essentially speccing something that's deprecated. (i.e., we wouldn't recommend sites use bipe.prompt; rather just always use requestInstallPrompt.

except, browser no longer fire this on their own!

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?)

It sounds like you're suggesting we have both the global method and the existing beforeinstallprompt event.

Yes... for backwards compat. I hadn't thought this through...

So in the reprompt case, you're saying that we would only fire BIP once, but then you could call navigator.requestInstallPrompt many times even after the user has cancelled the first one?

Yeah, and then we can rate limit, etc.

It just seems like bipe.prompt() is going to be redundant, so if we spec both, we might end up essentially speccing something that's deprecated. (i.e., we wouldn't recommend sites use bipe.prompt; rather just always use requestInstallPrompt.

Yeah, essentially, bipe.prompt() would have just called .requestInstallPrompt().

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?)

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??

Was this page helpful?
0 / 5 - 0 ratings

Related issues

photopea picture photopea  Â·  7Comments

TorstenDittmann picture TorstenDittmann  Â·  5Comments

christianliebel picture christianliebel  Â·  6Comments

kenchris picture kenchris  Â·  8Comments

mgiuca picture mgiuca  Â·  8Comments