зеркало из https://github.com/mozilla/gecko-dev.git
Bug 462549: Verify that an appcache manifest hasn't changed at the end of an update. r+sr=biesi
This commit is contained in:
Родитель
c27c345970
Коммит
19d2223559
|
@ -56,6 +56,7 @@ _TEST_FILES = \
|
|||
test_simpleManifest.html \
|
||||
test_identicalManifest.html \
|
||||
test_changingManifest.html \
|
||||
test_refetchManifest.html \
|
||||
test_offlineIFrame.html \
|
||||
test_bug445544.html \
|
||||
445544_part1.html \
|
||||
|
@ -88,7 +89,8 @@ _TEST_FILES = \
|
|||
simpleManifest.notmanifest \
|
||||
changing1Sec.sjs \
|
||||
changing1Hour.sjs \
|
||||
changingManifest.sjs \
|
||||
changingManifest.cacheManifest \
|
||||
changingManifest.cacheManifest^headers^ \
|
||||
offlineChild.html \
|
||||
$(NULL)
|
||||
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
CACHE MANIFEST
|
||||
# v1 - this will be replaced by test scripts.
|
||||
changing1Hour.sjs
|
||||
changing1Sec.sjs
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
Content-Type: text/cache-manifest
|
||||
Cache-Control: no-cache
|
|
@ -1,12 +0,0 @@
|
|||
function handleRequest(request, response)
|
||||
{
|
||||
response.setStatusLine(request.httpVersion, 200, "Ok");
|
||||
response.setHeader("Content-Type", "text/cache-manifest");
|
||||
response.setHeader("Cache-Control", "no-cache");
|
||||
|
||||
response.write("CACHE MANIFEST\n");
|
||||
response.write("#" + Date.now() + "\n");
|
||||
response.write("http://localhost:8888/tests/dom/tests/mochitest/ajax/offline/changing1Hour.sjs\n");
|
||||
response.write("http://localhost:8888/tests/dom/tests/mochitest/ajax/offline/changing1Sec.sjs\n");
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
<html xmlns="http://www.w3.org/1999/xhtml" manifest="http://localhost:8888/tests/dom/tests/mochitest/ajax/offline/changingManifest.sjs">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" manifest="http://localhost:8888/tests/dom/tests/mochitest/ajax/offline/changingManifest.cacheManifest">
|
||||
<head>
|
||||
<title>changing manifest test</title>
|
||||
|
||||
|
@ -17,6 +17,17 @@ var g1HourUrl = "http://localhost:8888/tests/dom/tests/mochitest/ajax/offline/ch
|
|||
|
||||
var gCacheContents = null;
|
||||
|
||||
function finish()
|
||||
{
|
||||
// Get rid of the changed manifest.
|
||||
var req = new XMLHttpRequest();
|
||||
req.open("DELETE", "changingManifest.cacheManifest", false);
|
||||
req.send("");
|
||||
|
||||
OfflineTest.teardown();
|
||||
OfflineTest.finish();
|
||||
}
|
||||
|
||||
function manifestUpdatedAgain()
|
||||
{
|
||||
OfflineTest.ok(gGotChecking, "Should get a checking event on the second update");
|
||||
|
@ -30,15 +41,13 @@ function manifestUpdatedAgain()
|
|||
OfflineTest.isnot(gCacheContents[g1SecUrl], contents[g1SecUrl], "1-second expiration should have changed");
|
||||
OfflineTest.is(gCacheContents[g1HourUrl], contents[g1HourUrl], "1-hour expiration should not have changed");
|
||||
|
||||
OfflineTest.teardown();
|
||||
OfflineTest.finish();
|
||||
finish();
|
||||
});
|
||||
}
|
||||
|
||||
function failAndFinish(e) {
|
||||
OfflineTest.ok(false, "Unexpected event: " + e.type);
|
||||
OfflineTest.teardown();
|
||||
OfflineTest.finish();
|
||||
finish();
|
||||
}
|
||||
|
||||
function manifestUpdated()
|
||||
|
@ -46,12 +55,25 @@ function manifestUpdated()
|
|||
OfflineTest.ok(gGotChecking, "Should get a checking event");
|
||||
OfflineTest.ok(gGotDownloading, "Should get a downloading event");
|
||||
|
||||
// Replace this manifest with a new one.
|
||||
|
||||
// XXX: After this put, we will no longer have Cache-Control:
|
||||
// no-cache on the manifest, so future updates will just use the
|
||||
// cached manifest.
|
||||
var req = new XMLHttpRequest();
|
||||
req.open("PUT", "changingManifest.cacheManifest", false);
|
||||
req.setRequestHeader("Content-Type", "text/cache-manifest");
|
||||
req.send("CACHE MANIFEST\n" +
|
||||
"# v2\n" +
|
||||
"changing1Hour.sjs\n" +
|
||||
"changing1Sec.sjs\n");
|
||||
|
||||
// Get the initial contents of the first two files.
|
||||
fetcher = new OfflineCacheContents([g1SecUrl, g1HourUrl]);
|
||||
fetcher.fetch(function(contents) {
|
||||
gCacheContents = contents;
|
||||
|
||||
// Now make sure applicationCache.update() does what we expect.
|
||||
// Now make sure applicationCache.update() does what we expect.
|
||||
applicationCache.onupdateready = OfflineTest.priv(manifestUpdatedAgain);
|
||||
applicationCache.onnoupdate = failAndFinish;
|
||||
applicationCache.oncached = failAndFinish;
|
||||
|
|
|
@ -0,0 +1,103 @@
|
|||
<html xmlns="http://www.w3.org/1999/xhtml" manifest="http://localhost:8888/tests/dom/tests/mochitest/ajax/offline/changingManifest.cacheManifest">
|
||||
<head>
|
||||
<title>refetch manifest test</title>
|
||||
|
||||
<script type="text/javascript" src="/MochiKit/packed.js"></script>
|
||||
<script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
|
||||
<script type="text/javascript" src="/tests/dom/tests/mochitest/ajax/offline/offlineTests.js"></script>
|
||||
|
||||
<script type="text/javascript">
|
||||
|
||||
function finish()
|
||||
{
|
||||
// Get rid of the changed manifest.
|
||||
var req = new XMLHttpRequest();
|
||||
req.open("DELETE", "changingManifest.cacheManifest", false);
|
||||
req.send("");
|
||||
|
||||
OfflineTest.teardown();
|
||||
OfflineTest.finish();
|
||||
}
|
||||
|
||||
function failAndFinish(e)
|
||||
{
|
||||
OfflineTest.ok(false, "Unexpected event: " + e.type);
|
||||
finish();
|
||||
}
|
||||
|
||||
function manifestUpdated()
|
||||
{
|
||||
// Replace this manifest with a new one.
|
||||
|
||||
// XXX: After this put, we will no longer have Cache-Control:
|
||||
// no-cache on the manifest, so future updates will just use the
|
||||
// cached manifest.
|
||||
|
||||
// Get the initial contents of the first two files.
|
||||
fetcher = new OfflineCacheContents([g1SecUrl, g1HourUrl]);
|
||||
fetcher.fetch(function(contents) {
|
||||
gCacheContents = contents;
|
||||
|
||||
// Now make sure applicationCache.update() does what we expect.
|
||||
applicationCache.onupdateready = OfflineTest.priv(manifestUpdatedAgain);
|
||||
applicationCache.onnoupdate = failAndFinish;
|
||||
applicationCache.oncached = failAndFinish;
|
||||
|
||||
gGotChecking = false;
|
||||
gGotDownloading = false;
|
||||
|
||||
// The changing versions give out a new version each second,
|
||||
// make sure it has time to grab a new version, and for the
|
||||
// 1-second cache timeout to pass.
|
||||
window.setTimeout("applicationCache.update()", 5000);
|
||||
});
|
||||
}
|
||||
|
||||
function replaceManifest()
|
||||
{
|
||||
// If we replace the manifest after a downloading event, the update
|
||||
// should fail when it revalidates the manifest at the end of the update.
|
||||
|
||||
var req = new XMLHttpRequest();
|
||||
req.open("PUT", "changingManifest.cacheManifest", false);
|
||||
req.setRequestHeader("Content-Type", "text/cache-manifest");
|
||||
req.send("CACHE MANIFEST\n" +
|
||||
"# v2\n" +
|
||||
"changing1Hour.sjs\n" +
|
||||
"changing1Sec.sjs\n");
|
||||
|
||||
}
|
||||
|
||||
function cached()
|
||||
{
|
||||
OfflineTest.ok(true, "Got the expected cached event.");
|
||||
finish();
|
||||
}
|
||||
|
||||
function gotError()
|
||||
{
|
||||
OfflineTest.ok(true, "Got the expected error event.");
|
||||
|
||||
// Now this update will be rescheduled, and it should succeed.
|
||||
applicationCache.onerror = failAndFinish;
|
||||
applicationCache.oncached = cached;
|
||||
}
|
||||
|
||||
if (OfflineTest.setup()) {
|
||||
applicationCache.onerror = gotError;
|
||||
applicationCache.onnoupdate = failAndFinish;
|
||||
|
||||
applicationCache.ondownloading = replaceManifest;
|
||||
applicationCache.oncached = failAndFinish;
|
||||
}
|
||||
|
||||
SimpleTest.waitForExplicitFinish();
|
||||
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
</body>
|
||||
</html>
|
|
@ -68,6 +68,8 @@
|
|||
|
||||
static nsOfflineCacheUpdateService *gOfflineCacheUpdateService = nsnull;
|
||||
|
||||
static const PRUint32 kRescheduleLimit = 3;
|
||||
|
||||
#if defined(PR_LOGGING)
|
||||
//
|
||||
// To enable logging (see prlog.h for full details):
|
||||
|
@ -105,6 +107,188 @@ DropReferenceFromURL(nsIURI * aURI)
|
|||
return NS_OK;
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// nsManifestCheck
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
class nsManifestCheck : public nsIStreamListener
|
||||
, public nsIChannelEventSink
|
||||
, public nsIInterfaceRequestor
|
||||
{
|
||||
public:
|
||||
nsManifestCheck(nsOfflineCacheUpdate *aUpdate,
|
||||
nsIURI *aURI,
|
||||
nsIURI *aReferrerURI)
|
||||
: mUpdate(aUpdate)
|
||||
, mURI(aURI)
|
||||
, mReferrerURI(aReferrerURI)
|
||||
{}
|
||||
|
||||
NS_DECL_ISUPPORTS
|
||||
NS_DECL_NSIREQUESTOBSERVER
|
||||
NS_DECL_NSISTREAMLISTENER
|
||||
NS_DECL_NSICHANNELEVENTSINK
|
||||
NS_DECL_NSIINTERFACEREQUESTOR
|
||||
|
||||
nsresult Begin();
|
||||
|
||||
private:
|
||||
|
||||
static NS_METHOD ReadManifest(nsIInputStream *aInputStream,
|
||||
void *aClosure,
|
||||
const char *aFromSegment,
|
||||
PRUint32 aOffset,
|
||||
PRUint32 aCount,
|
||||
PRUint32 *aBytesConsumed);
|
||||
|
||||
nsRefPtr<nsOfflineCacheUpdate> mUpdate;
|
||||
nsCOMPtr<nsIURI> mURI;
|
||||
nsCOMPtr<nsIURI> mReferrerURI;
|
||||
nsCOMPtr<nsICryptoHash> mManifestHash;
|
||||
nsCOMPtr<nsIChannel> mChannel;
|
||||
};
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// nsManifestCheck::nsISupports
|
||||
//-----------------------------------------------------------------------------
|
||||
NS_IMPL_ISUPPORTS4(nsManifestCheck,
|
||||
nsIRequestObserver,
|
||||
nsIStreamListener,
|
||||
nsIChannelEventSink,
|
||||
nsIInterfaceRequestor)
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// nsManifestCheck <public>
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
nsresult
|
||||
nsManifestCheck::Begin()
|
||||
{
|
||||
nsresult rv;
|
||||
mManifestHash = do_CreateInstance("@mozilla.org/security/hash;1", &rv);
|
||||
NS_ENSURE_SUCCESS(rv, rv);
|
||||
|
||||
rv = mManifestHash->Init(nsICryptoHash::MD5);
|
||||
NS_ENSURE_SUCCESS(rv, rv);
|
||||
|
||||
rv = NS_NewChannel(getter_AddRefs(mChannel),
|
||||
mURI,
|
||||
nsnull, nsnull, nsnull,
|
||||
nsIRequest::LOAD_BYPASS_CACHE);
|
||||
NS_ENSURE_SUCCESS(rv, rv);
|
||||
|
||||
// configure HTTP specific stuff
|
||||
nsCOMPtr<nsIHttpChannel> httpChannel =
|
||||
do_QueryInterface(mChannel);
|
||||
if (httpChannel) {
|
||||
httpChannel->SetReferrer(mReferrerURI);
|
||||
httpChannel->SetRequestHeader(NS_LITERAL_CSTRING("X-Moz"),
|
||||
NS_LITERAL_CSTRING("offline-resource"),
|
||||
PR_FALSE);
|
||||
}
|
||||
|
||||
rv = mChannel->AsyncOpen(this, nsnull);
|
||||
NS_ENSURE_SUCCESS(rv, rv);
|
||||
|
||||
return NS_OK;
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// nsManifestCheck <public>
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
/* static */
|
||||
NS_METHOD
|
||||
nsManifestCheck::ReadManifest(nsIInputStream *aInputStream,
|
||||
void *aClosure,
|
||||
const char *aFromSegment,
|
||||
PRUint32 aOffset,
|
||||
PRUint32 aCount,
|
||||
PRUint32 *aBytesConsumed)
|
||||
{
|
||||
nsManifestCheck *manifestCheck =
|
||||
static_cast<nsManifestCheck*>(aClosure);
|
||||
|
||||
nsresult rv;
|
||||
*aBytesConsumed = aCount;
|
||||
|
||||
rv = manifestCheck->mManifestHash->Update(
|
||||
reinterpret_cast<const PRUint8 *>(aFromSegment), aCount);
|
||||
NS_ENSURE_SUCCESS(rv, rv);
|
||||
|
||||
return NS_OK;
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// nsManifestCheck::nsIStreamListener
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
NS_IMETHODIMP
|
||||
nsManifestCheck::OnStartRequest(nsIRequest *aRequest,
|
||||
nsISupports *aContext)
|
||||
{
|
||||
return NS_OK;
|
||||
}
|
||||
|
||||
NS_IMETHODIMP
|
||||
nsManifestCheck::OnDataAvailable(nsIRequest *aRequest,
|
||||
nsISupports *aContext,
|
||||
nsIInputStream *aStream,
|
||||
PRUint32 aOffset,
|
||||
PRUint32 aCount)
|
||||
{
|
||||
PRUint32 bytesRead;
|
||||
aStream->ReadSegments(ReadManifest, this, aCount, &bytesRead);
|
||||
return NS_OK;
|
||||
}
|
||||
|
||||
NS_IMETHODIMP
|
||||
nsManifestCheck::OnStopRequest(nsIRequest *aRequest,
|
||||
nsISupports *aContext,
|
||||
nsresult aStatus)
|
||||
{
|
||||
nsCAutoString manifestHash;
|
||||
if (NS_SUCCEEDED(aStatus)) {
|
||||
mManifestHash->Finish(PR_TRUE, manifestHash);
|
||||
}
|
||||
|
||||
mUpdate->ManifestCheckCompleted(aStatus, manifestHash);
|
||||
|
||||
return NS_OK;
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// nsManifestCheck::nsIInterfaceRequestor
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
NS_IMETHODIMP
|
||||
nsManifestCheck::GetInterface(const nsIID &aIID, void **aResult)
|
||||
{
|
||||
if (aIID.Equals(NS_GET_IID(nsIChannelEventSink))) {
|
||||
NS_ADDREF_THIS();
|
||||
*aResult = static_cast<nsIChannelEventSink *>(this);
|
||||
return NS_OK;
|
||||
}
|
||||
|
||||
return NS_ERROR_NO_INTERFACE;
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// nsManifestCheck::nsIChannelEventSink
|
||||
//-----------------------------------------------------------------------------
|
||||
|
||||
NS_IMETHODIMP
|
||||
nsManifestCheck::OnChannelRedirect(nsIChannel *aOldChannel,
|
||||
nsIChannel *aNewChannel,
|
||||
PRUint32 aFlags)
|
||||
{
|
||||
// Redirects should cause the load (and therefore the update) to fail.
|
||||
if (aFlags & nsIChannelEventSink::REDIRECT_INTERNAL)
|
||||
return NS_OK;
|
||||
aOldChannel->Cancel(NS_ERROR_ABORT);
|
||||
return NS_ERROR_ABORT;
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------------------
|
||||
// nsOfflineCacheUpdateItem::nsISupports
|
||||
//-----------------------------------------------------------------------------
|
||||
|
@ -754,7 +938,7 @@ nsOfflineManifestItem::CheckNewManifestContentHash(nsIRequest *aRequest)
|
|||
}
|
||||
|
||||
nsCString newManifestHashValue;
|
||||
rv = mManifestHash->Finish(PR_TRUE, newManifestHashValue);
|
||||
rv = mManifestHash->Finish(PR_TRUE, mManifestHashValue);
|
||||
mManifestHash = nsnull;
|
||||
|
||||
if (NS_FAILED(rv)) {
|
||||
|
@ -768,7 +952,7 @@ nsOfflineManifestItem::CheckNewManifestContentHash(nsIRequest *aRequest)
|
|||
return NS_OK;
|
||||
}
|
||||
|
||||
if (mOldManifestHashValue == newManifestHashValue) {
|
||||
if (mOldManifestHashValue == mManifestHashValue) {
|
||||
LOG(("Update not needed, downloaded manifest content is byte-for-byte identical"));
|
||||
mNeedsUpdate = PR_FALSE;
|
||||
}
|
||||
|
@ -784,7 +968,7 @@ nsOfflineManifestItem::CheckNewManifestContentHash(nsIRequest *aRequest)
|
|||
nsCOMPtr<nsICacheEntryDescriptor> cacheDescriptor(do_QueryInterface(cacheToken, &rv));
|
||||
NS_ENSURE_SUCCESS(rv, rv);
|
||||
|
||||
rv = cacheDescriptor->SetMetaDataElement("offline-manifest-hash", PromiseFlatCString(newManifestHashValue).get());
|
||||
rv = cacheDescriptor->SetMetaDataElement("offline-manifest-hash", mManifestHashValue.get());
|
||||
NS_ENSURE_SUCCESS(rv, rv);
|
||||
}
|
||||
|
||||
|
@ -904,6 +1088,7 @@ nsOfflineCacheUpdate::nsOfflineCacheUpdate()
|
|||
, mSucceeded(PR_TRUE)
|
||||
, mObsolete(PR_FALSE)
|
||||
, mCurrentItem(-1)
|
||||
, mRescheduleCount(0)
|
||||
{
|
||||
}
|
||||
|
||||
|
@ -1199,6 +1384,40 @@ nsOfflineCacheUpdate::LoadCompleted()
|
|||
ProcessNextURI();
|
||||
}
|
||||
|
||||
void
|
||||
nsOfflineCacheUpdate::ManifestCheckCompleted(nsresult aStatus,
|
||||
const nsCString &aManifestHash)
|
||||
{
|
||||
if (NS_SUCCEEDED(aStatus)) {
|
||||
nsCAutoString firstManifestHash;
|
||||
mManifestItem->GetManifestHash(firstManifestHash);
|
||||
if (aManifestHash != firstManifestHash) {
|
||||
aStatus = NS_ERROR_FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
if (NS_FAILED(aStatus)) {
|
||||
mSucceeded = PR_FALSE;
|
||||
NotifyError();
|
||||
}
|
||||
|
||||
Finish();
|
||||
|
||||
if (NS_FAILED(aStatus) && mRescheduleCount < kRescheduleLimit) {
|
||||
// Reschedule this update.
|
||||
nsRefPtr<nsOfflineCacheUpdate> newUpdate =
|
||||
new nsOfflineCacheUpdate();
|
||||
newUpdate->Init(mManifestURI, mDocumentURI);
|
||||
|
||||
for (PRInt32 i = 0; i < mDocuments.Count(); i++) {
|
||||
newUpdate->AddDocument(mDocuments[i]);
|
||||
}
|
||||
|
||||
newUpdate->mRescheduleCount = mRescheduleCount + 1;
|
||||
newUpdate->Schedule();
|
||||
}
|
||||
}
|
||||
|
||||
nsresult
|
||||
nsOfflineCacheUpdate::Begin()
|
||||
{
|
||||
|
@ -1310,7 +1529,23 @@ nsOfflineCacheUpdate::ProcessNextURI()
|
|||
"ProcessNextURI should only be called from the DOWNLOADING state");
|
||||
|
||||
if (mCurrentItem >= static_cast<PRInt32>(mItems.Length())) {
|
||||
return Finish();
|
||||
if (mPartialUpdate) {
|
||||
return Finish();
|
||||
} else {
|
||||
// Verify that the manifest wasn't changed during the
|
||||
// update, to prevent capturing a cache while the server
|
||||
// is being updated. The check will call
|
||||
// ManifestCheckCompleted() when it's done.
|
||||
nsRefPtr<nsManifestCheck> manifestCheck =
|
||||
new nsManifestCheck(this, mManifestURI, mDocumentURI);
|
||||
if (NS_FAILED(manifestCheck->Begin())) {
|
||||
mSucceeded = PR_FALSE;
|
||||
NotifyError();
|
||||
return Finish();
|
||||
}
|
||||
|
||||
return NS_OK;
|
||||
}
|
||||
}
|
||||
|
||||
#if defined(PR_LOGGING)
|
||||
|
|
|
@ -138,6 +138,9 @@ public:
|
|||
{ return (mParserState != PARSE_INIT && mParserState != PARSE_ERROR); }
|
||||
PRBool NeedsUpdate() { return mParserState != PARSE_INIT && mNeedsUpdate; }
|
||||
|
||||
void GetManifestHash(nsCString &aManifestHash)
|
||||
{ aManifestHash = mManifestHashValue; }
|
||||
|
||||
private:
|
||||
static NS_METHOD ReadManifest(nsIInputStream *aInputStream,
|
||||
void *aClosure,
|
||||
|
@ -196,6 +199,7 @@ private:
|
|||
// manifest hash data
|
||||
nsCOMPtr<nsICryptoHash> mManifestHash;
|
||||
PRBool mManifestHashInitialized;
|
||||
nsCString mManifestHashValue;
|
||||
nsCString mOldManifestHashValue;
|
||||
};
|
||||
|
||||
|
@ -216,6 +220,8 @@ public:
|
|||
nsresult Cancel();
|
||||
|
||||
void LoadCompleted();
|
||||
void ManifestCheckCompleted(nsresult aStatus,
|
||||
const nsCString &aManifestHash);
|
||||
|
||||
void AddDocument(nsIDOMDocument *aDocument) {
|
||||
mDocuments.AppendObject(aDocument);
|
||||
|
@ -281,6 +287,10 @@ private:
|
|||
|
||||
/* Documents that requested this update */
|
||||
nsCOMArray<nsIDOMDocument> mDocuments;
|
||||
|
||||
/* Reschedule count. When an update is rescheduled due to
|
||||
* mismatched manifests, the reschedule count will be increased. */
|
||||
PRUint32 mRescheduleCount;
|
||||
};
|
||||
|
||||
class nsOfflineCacheUpdateService : public nsIOfflineCacheUpdateService
|
||||
|
|
Загрузка…
Ссылка в новой задаче