mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2025-08-28 05:53:59 +02:00
commit
93cb319e97
12 changed files with 537 additions and 125 deletions
|
@ -9,8 +9,8 @@ android {
|
||||||
|
|
||||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation project(':byteowls-capacitor-oauth2')
|
|
||||||
implementation project(':capacitor-app')
|
implementation project(':capacitor-app')
|
||||||
|
implementation project(':capacitor-browser')
|
||||||
implementation project(':capacitor-clipboard')
|
implementation project(':capacitor-clipboard')
|
||||||
implementation project(':capacitor-dialog')
|
implementation project(':capacitor-dialog')
|
||||||
implementation project(':capacitor-haptics')
|
implementation project(':capacitor-haptics')
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
<string name="app_name">audiobookshelf</string>
|
<string name="app_name">audiobookshelf</string>
|
||||||
<string name="title_activity_main">audiobookshelf</string>
|
<string name="title_activity_main">audiobookshelf</string>
|
||||||
<string name="package_name">com.audiobookshelf.app</string>
|
<string name="package_name">com.audiobookshelf.app</string>
|
||||||
<string name="custom_url_scheme">com.audiobookshelf.app.debug</string>
|
<string name="custom_url_scheme">audiobookshelf</string>
|
||||||
<string name="add_widget">Add widget</string>
|
<string name="add_widget">Add widget</string>
|
||||||
<string name="app_widget_description">Simple widget for audiobookshelf playback</string>
|
<string name="app_widget_description">Simple widget for audiobookshelf playback</string>
|
||||||
<string name="action_jump_forward">Jump Forward</string>
|
<string name="action_jump_forward">Jump Forward</string>
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
[
|
[
|
||||||
{
|
|
||||||
"pkg": "@byteowls/capacitor-oauth2",
|
|
||||||
"classpath": "com.byteowls.capacitor.oauth2.OAuth2ClientPlugin"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"pkg": "@capacitor/app",
|
"pkg": "@capacitor/app",
|
||||||
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
|
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"pkg": "@capacitor/browser",
|
||||||
|
"classpath": "com.capacitorjs.plugins.browser.BrowserPlugin"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"pkg": "@capacitor/clipboard",
|
"pkg": "@capacitor/clipboard",
|
||||||
"classpath": "com.capacitorjs.plugins.clipboard.ClipboardPlugin"
|
"classpath": "com.capacitorjs.plugins.clipboard.ClipboardPlugin"
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
<string name="app_name">audiobookshelf</string>
|
<string name="app_name">audiobookshelf</string>
|
||||||
<string name="title_activity_main">audiobookshelf</string>
|
<string name="title_activity_main">audiobookshelf</string>
|
||||||
<string name="package_name">com.audiobookshelf.app</string>
|
<string name="package_name">com.audiobookshelf.app</string>
|
||||||
<string name="custom_url_scheme">com.audiobookshelf.app</string>
|
<string name="custom_url_scheme">audiobookshelf</string>
|
||||||
<string name="add_widget">Add widget</string>
|
<string name="add_widget">Add widget</string>
|
||||||
<string name="app_widget_description">Simple widget for audiobookshelf playback</string>
|
<string name="app_widget_description">Simple widget for audiobookshelf playback</string>
|
||||||
<string name="action_jump_forward">Jump Forward</string>
|
<string name="action_jump_forward">Jump Forward</string>
|
||||||
|
|
|
@ -2,12 +2,12 @@
|
||||||
include ':capacitor-android'
|
include ':capacitor-android'
|
||||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||||
|
|
||||||
include ':byteowls-capacitor-oauth2'
|
|
||||||
project(':byteowls-capacitor-oauth2').projectDir = new File('../node_modules/@byteowls/capacitor-oauth2/android')
|
|
||||||
|
|
||||||
include ':capacitor-app'
|
include ':capacitor-app'
|
||||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||||
|
|
||||||
|
include ':capacitor-browser'
|
||||||
|
project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/android')
|
||||||
|
|
||||||
include ':capacitor-clipboard'
|
include ':capacitor-clipboard'
|
||||||
project(':capacitor-clipboard').projectDir = new File('../node_modules/@capacitor/clipboard/android')
|
project(':capacitor-clipboard').projectDir = new File('../node_modules/@capacitor/clipboard/android')
|
||||||
|
|
||||||
|
|
|
@ -51,8 +51,8 @@
|
||||||
<ui-btn :disabled="processing || !networkConnected" type="submit" class="mt-1 h-10">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn>
|
<ui-btn :disabled="processing || !networkConnected" type="submit" class="mt-1 h-10">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div v-if="isLocalAuthEnabled && isOpenIDAuthEnabled" class="w-full h-px bg-white bg-opacity-10 my-2" />
|
<div v-if="isLocalAuthEnabled && isOpenIDAuthEnabled" class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||||
<ui-btn v-if="isOpenIDAuthEnabled" :disabled="processing" class="mt-1 h-10" @click="clickLoginWithOpenId">Login with OpenId</ui-btn>
|
<ui-btn v-if="isOpenIDAuthEnabled" :disabled="processing" class="h-10 w-full" @click="clickLoginWithOpenId">{{ oauth.buttonText }}</ui-btn>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -78,9 +78,12 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { Dialog } from '@capacitor/dialog'
|
import { Browser } from '@capacitor/browser'
|
||||||
import { CapacitorHttp } from '@capacitor/core'
|
import { CapacitorHttp } from '@capacitor/core'
|
||||||
import { OAuth2Client } from '@byteowls/capacitor-oauth2'
|
import { Dialog } from '@capacitor/dialog'
|
||||||
|
|
||||||
|
// TODO: when backend ready. See validateLoginFormResponse()
|
||||||
|
//const requiredServerVersion = '2.5.0'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
|
@ -97,7 +100,13 @@ export default {
|
||||||
error: null,
|
error: null,
|
||||||
showForm: false,
|
showForm: false,
|
||||||
showAddCustomHeaders: false,
|
showAddCustomHeaders: false,
|
||||||
authMethods: []
|
authMethods: [],
|
||||||
|
oauth: {
|
||||||
|
state: null,
|
||||||
|
verifier: null,
|
||||||
|
challenge: null,
|
||||||
|
buttonText: 'Login with OpenID'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
@ -129,41 +138,243 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
/**
|
||||||
|
* Initiates the login process using OpenID via OAuth2.0.
|
||||||
|
* 1. Verifying the server's address
|
||||||
|
* 2. Calling oauthRequest() to obtain the special OpenID redirect URL
|
||||||
|
* including a challenge and specying audiobookshelf://oauth as redirect URL
|
||||||
|
* 3. Open this redirect URL in browser (which is a website of the SSO provider)
|
||||||
|
*
|
||||||
|
* When the browser is open, the following flow is expected:
|
||||||
|
* a. The user authenticates and the provider redirects back to custom URL audiobookshelf://oauth
|
||||||
|
* b. The app calls appUrlOpen() when `audiobookshelf://oauth` is called
|
||||||
|
* b. appUrlOpen() handles the incoming URL and extracts the authorization code from GET parameter
|
||||||
|
* c. oauthExchangeCodeForToken() exchanges the authorization code for an access token
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @throws Will log a console error if the browser fails to open the URL and display errors via this.error to the user.
|
||||||
|
*/
|
||||||
async clickLoginWithOpenId() {
|
async clickLoginWithOpenId() {
|
||||||
this.error = ''
|
// oauth standard requires https explicitly
|
||||||
const options = {
|
if (!this.serverConfig.address.startsWith('https')) {
|
||||||
authorizationBaseUrl: `${this.serverConfig.address}/auth/openid`,
|
console.warn(`[SSO] Oauth2 requires HTTPS`)
|
||||||
logsEnabled: true,
|
this.$toast.error(`SSO: The URL to the server must be https:// secured`)
|
||||||
web: {
|
return
|
||||||
appId: 'com.audiobookshelf.web',
|
|
||||||
responseType: 'token',
|
|
||||||
redirectUrl: location.origin
|
|
||||||
},
|
|
||||||
android: {
|
|
||||||
appId: 'com.audiobookshelf.app',
|
|
||||||
responseType: 'code',
|
|
||||||
redirectUrl: 'com.audiobookshelf.app:/'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
OAuth2Client.authenticate(options)
|
|
||||||
.then(async (response) => {
|
// First request that we want to do oauth/openid and get the URL which a browser window should open
|
||||||
const token = response.authorization_response?.additional_parameters?.setToken || response.authorization_response?.setToken
|
const redirectUrl = await this.oauthRequest(this.serverConfig.address)
|
||||||
if (token) {
|
if (!redirectUrl) {
|
||||||
this.serverConfig.token = token
|
// error message handled by oauthRequest
|
||||||
const payload = await this.authenticateToken()
|
return
|
||||||
if (payload) {
|
}
|
||||||
this.setUserAndConnection(payload)
|
|
||||||
} else {
|
// Actually we should be able to use the redirectUrl directly for Browser.open below
|
||||||
this.showAuth = true
|
// However it seems that when directly using it there is a malformation and leads to the error
|
||||||
}
|
// Unhandled Promise Rejection: DataCloneError: The object can not be cloned.
|
||||||
} else {
|
// (On calling Browser.open)
|
||||||
this.error = 'Invalid response: No token'
|
// Which is hard to debug
|
||||||
|
// So we simply extract the important elements and build the required URL ourselves
|
||||||
|
// which also has the advantage that we can replace the callbackurl with the app url
|
||||||
|
|
||||||
|
const client_id = redirectUrl.searchParams.get('client_id')
|
||||||
|
const scope = redirectUrl.searchParams.get('scope')
|
||||||
|
const state = redirectUrl.searchParams.get('state')
|
||||||
|
|
||||||
|
if (!client_id || !scope || !state) {
|
||||||
|
console.warn(`[SSO] Invalid OpenID URL - client_id scope or state missing: ${redirectUrl}`)
|
||||||
|
this.$toast.error(`SSO: Invalid answer`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (redirectUrl.protocol !== 'https:') {
|
||||||
|
console.warn(`[SSO] Insecure Redirection by SSO provider: ${redirectUrl.protocol} is not allowed. Use HTTPS`)
|
||||||
|
this.$toast.error(`SSO: The SSO provider must return a HTTPS secured URL`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// We need to verify if the state is the same later
|
||||||
|
this.oauth.state = state
|
||||||
|
|
||||||
|
const host = `https://${redirectUrl.host}`
|
||||||
|
const buildUrl = `${host}${redirectUrl.pathname}?response_type=code` + `&client_id=${encodeURIComponent(client_id)}&scope=${encodeURIComponent(scope)}&state=${encodeURIComponent(state)}` + `&redirect_uri=${encodeURIComponent('audiobookshelf://oauth')}` + `&code_challenge=${encodeURIComponent(this.oauth.challenge)}&code_challenge_method=S256`
|
||||||
|
|
||||||
|
// example url for authentik
|
||||||
|
// const authURL = "https://authentik/application/o/authorize/?response_type=code&client_id=41cd96f...&redirect_uri=audiobookshelf%3A%2F%2Foauth&scope=openid%20openid%20email%20profile&state=asdds..."
|
||||||
|
|
||||||
|
// Open the browser. The browser/identity provider in turn will redirect to an in-app link supplementing a code
|
||||||
|
try {
|
||||||
|
await Browser.open({ url: buildUrl })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error opening browser', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Requests the OAuth/OpenID URL from the backend server to open in browser
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {string} url - The base URL of the server to append the OAuth request parameters to.
|
||||||
|
* @return {Promise<URL|null>} OAuth URL which should be opened in a browser
|
||||||
|
* @throws Logs an error and displays a toast notification if the token exchange fails.
|
||||||
|
*/
|
||||||
|
async oauthRequest(url) {
|
||||||
|
// Generate oauth2 PKCE challenge
|
||||||
|
// In accordance to RFC 7636 Section 4
|
||||||
|
function base64URLEncode(arrayBuffer) {
|
||||||
|
let base64String = btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)))
|
||||||
|
return base64String.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256(plain) {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const data = encoder.encode(plain)
|
||||||
|
return await window.crypto.subtle.digest('SHA-256', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateRandomString() {
|
||||||
|
var array = new Uint32Array(42)
|
||||||
|
window.crypto.getRandomValues(array)
|
||||||
|
return Array.from(array, (dec) => ('0' + dec.toString(16)).slice(-2)).join('') // hex
|
||||||
|
}
|
||||||
|
|
||||||
|
const verifier = generateRandomString()
|
||||||
|
|
||||||
|
const challenge = base64URLEncode(await sha256(verifier))
|
||||||
|
|
||||||
|
this.oauth.verifier = verifier
|
||||||
|
this.oauth.challenge = challenge
|
||||||
|
|
||||||
|
// set parameter isRest to true, so the backend wont attempt a redirect after we call backend:/callback in exchangeCodeForToken
|
||||||
|
const backendEndpoint = `${url}auth/openid?code_challenge=${challenge}&code_challenge_method=S256&isRest=true`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await CapacitorHttp.get({
|
||||||
|
url: backendEndpoint,
|
||||||
|
disableRedirects: true,
|
||||||
|
webFetchExtra: {
|
||||||
|
redirect: 'manual'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
|
||||||
console.error('OAuth rejected', error)
|
// Every kind of redirection is allowed [RFC6749 - 1.7]
|
||||||
this.error = error.toString?.() || error.message
|
if (!(response.status >= 300 && response.status < 400)) {
|
||||||
|
throw new Error(`Unexpected response from server: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Depending on iOS or Android, it can be location or Location...
|
||||||
|
const locationHeader = response.headers[Object.keys(response.headers).find((key) => key.toLowerCase() === 'location')]
|
||||||
|
if (!locationHeader) {
|
||||||
|
throw new Error(`No location header in SSO answer`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(locationHeader)
|
||||||
|
return url
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[SSO] ${error.message}`)
|
||||||
|
this.$toast.error(`SSO Error: ${error.message}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Handles the callback received from the OAuth/OpenID provider.
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @function appUrlOpen
|
||||||
|
* @param {string} url - The callback URL received from the OAuth/OpenID provider.
|
||||||
|
* @throws Logs a warning and displays a toast notification if the URL is invalid or the state doesn't match.
|
||||||
|
*/
|
||||||
|
async appUrlOpen(url) {
|
||||||
|
if (!url) return
|
||||||
|
|
||||||
|
// Handle the OAuth callback
|
||||||
|
const urlObj = new URL(url)
|
||||||
|
|
||||||
|
// audiobookshelf://oauth?code...
|
||||||
|
// urlObj.hostname for iOS and urlObj.pathname for android
|
||||||
|
if (url.startsWith('audiobookshelf://oauth')) {
|
||||||
|
// Extract possible errors thrown by the SSO provider
|
||||||
|
const authError = urlObj.searchParams.get('error')
|
||||||
|
if (authError) {
|
||||||
|
console.warn(`[SSO] Received the following error: ${authError}`)
|
||||||
|
this.$toast.error(`SSO: Received the following error: ${authError}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract oauth2 code to be exchanged for a token
|
||||||
|
const authCode = urlObj.searchParams.get('code')
|
||||||
|
// Extract the state variable
|
||||||
|
const state = urlObj.searchParams.get('state')
|
||||||
|
|
||||||
|
if (this.oauth.state !== state) {
|
||||||
|
console.warn(`[SSO] Wrong state returned by SSO Provider`)
|
||||||
|
this.$toast.error(`SSO: The response from the SSO Provider was invalid (wrong state)`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the state variable from the component config
|
||||||
|
this.oauth.state = null
|
||||||
|
|
||||||
|
if (authCode) {
|
||||||
|
await this.oauthExchangeCodeForToken(authCode, state)
|
||||||
|
} else {
|
||||||
|
console.warn(`[SSO] No code received`)
|
||||||
|
this.$toast.error(`SSO: The response from the SSO Provider did not include a code (authentication error?)`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`[ServerConnectForm] appUrlOpen: Unknown url: ${url} - host: ${urlObj.hostname} - path: ${urlObj.pathname}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Exchanges an oauth2 authorization code for a JWT token.
|
||||||
|
* And uses that token to finalise the log in process using authenticateToken()
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @function oauthExchangeCodeForToken
|
||||||
|
* @param {string} code - The authorization code provided by the OpenID provider.
|
||||||
|
* @param {string} state - The state value used to associate a client session with an ID token.
|
||||||
|
* @throws Logs an error and displays a toast notification if the token exchange fails.
|
||||||
|
*/
|
||||||
|
async oauthExchangeCodeForToken(code, state) {
|
||||||
|
// We need to read the url directly from this.serverConfig.address as the callback which is called via the external browser does not pass us that info
|
||||||
|
const backendEndpoint = `${this.serverConfig.address}auth/openid/callback?state=${encodeURIComponent(state)}&code=${encodeURIComponent(code)}&code_verifier=${encodeURIComponent(this.oauth.verifier)}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
// We can close the browser at this point (does not work on Android)
|
||||||
|
if (this.$platform === 'ios' || this.$platform === 'web') {
|
||||||
|
await Browser.close()
|
||||||
|
}
|
||||||
|
} catch (error) {} // No Error handling needed
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await CapacitorHttp.get({
|
||||||
|
url: backendEndpoint
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!response.data || !response.data.user || !response.data.user.token) {
|
||||||
|
throw new Error('Token data is missing in the response.')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.serverConfig.token = response.data.user.token
|
||||||
|
const payload = await this.authenticateToken()
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
throw new Error('Authentication failed with the provided token.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicateConfig = this.serverConnectionConfigs.find((scc) => scc.address === this.serverConfig.address && scc.username === payload.user.username)
|
||||||
|
if (duplicateConfig) {
|
||||||
|
throw new Error('Config already exists for this address and username.')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setUserAndConnection(payload)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SSO] Error in exchangeCodeForToken: ', error)
|
||||||
|
this.$toast.error(`SSO error: ${error.message || error}`)
|
||||||
|
} finally {
|
||||||
|
// We don't need the oauth verifier any more
|
||||||
|
this.oauth.verifier = null
|
||||||
|
this.oauth.challenge = null
|
||||||
|
}
|
||||||
},
|
},
|
||||||
addCustomHeaders() {
|
addCustomHeaders() {
|
||||||
this.showAddCustomHeaders = true
|
this.showAddCustomHeaders = true
|
||||||
|
@ -253,10 +464,18 @@ export default {
|
||||||
this.error = null
|
this.error = null
|
||||||
this.showAuth = false
|
this.showAuth = false
|
||||||
},
|
},
|
||||||
validateServerUrl(url) {
|
/**
|
||||||
|
* Validates a URL and reconstructs it with an optional protocol override.
|
||||||
|
* If the URL is invalid, null is returned.
|
||||||
|
*
|
||||||
|
* @param {string} url - The URL to validate.
|
||||||
|
* @param {string|null} [protocolOverride=null] - (Optional) Protocol to override the URL's original protocol.
|
||||||
|
* @returns {string|null} The validated URL with the original or overridden protocol, or null if invalid.
|
||||||
|
*/
|
||||||
|
validateServerUrl(url, protocolOverride = null) {
|
||||||
try {
|
try {
|
||||||
var urlObject = new URL(url)
|
var urlObject = new URL(url)
|
||||||
var address = `${urlObject.protocol}//${urlObject.hostname}`
|
var address = `${protocolOverride ? protocolOverride : urlObject.protocol}//${urlObject.hostname}`
|
||||||
if (urlObject.port) address += ':' + urlObject.port
|
if (urlObject.port) address += ':' + urlObject.port
|
||||||
return address
|
return address
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -264,18 +483,45 @@ export default {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Sends a GET request to the specified URL with the provided headers and timeout.
|
||||||
|
* If the response is successful (HTTP 200), the response object is returned.
|
||||||
|
* Otherwise, throws an error object containing code.
|
||||||
|
* code can be either a number, which is then a HTTP status code or
|
||||||
|
* a string, which is then a keyword like NSURLErrorBadURL when the TCP connection could not be established.
|
||||||
|
* When code is a string, error.message contains the human readable error by the OS or
|
||||||
|
* the http body of the non-200 answer.
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {string} url - The URL to which the GET request will be sent.
|
||||||
|
* @param {Object} headers - HTTP headers to be included in the request.
|
||||||
|
* @param {number} [connectTimeout=6000] - Timeout for the request in milliseconds.
|
||||||
|
* @returns {Promise<HttpResponse>} The HTTP response object if the request is successful.
|
||||||
|
* @throws {Error} An error with 'code' property set to the HTTP status code if the response is not successful.
|
||||||
|
* @throws {Error} An error with 'code' property set to the error code if the request fails.
|
||||||
|
*/
|
||||||
async getRequest(url, headers, connectTimeout = 6000) {
|
async getRequest(url, headers, connectTimeout = 6000) {
|
||||||
const options = {
|
const options = {
|
||||||
url,
|
url,
|
||||||
headers,
|
headers,
|
||||||
connectTimeout
|
connectTimeout
|
||||||
}
|
}
|
||||||
const response = await CapacitorHttp.get(options)
|
try {
|
||||||
console.log('[ServerConnectForm] GET request response', response)
|
const response = await CapacitorHttp.get(options)
|
||||||
if (response.status >= 400) {
|
console.log('[ServerConnectForm] GET request response', response)
|
||||||
throw new Error(response.data)
|
if (response.status == 200) {
|
||||||
} else {
|
return response
|
||||||
return response.data
|
} else {
|
||||||
|
// Put the HTTP error code inside the cause
|
||||||
|
let errorObj = new Error(response.data)
|
||||||
|
errorObj.code = response.status
|
||||||
|
throw errorObj
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Put the error name inside the cause (a string)
|
||||||
|
let errorObj = new Error(error.message)
|
||||||
|
errorObj.code = error.code
|
||||||
|
throw errorObj
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async postRequest(url, data, headers, connectTimeout = 6000) {
|
async postRequest(url, data, headers, connectTimeout = 6000) {
|
||||||
|
@ -301,23 +547,16 @@ export default {
|
||||||
* Get request to server /status api endpoint
|
* Get request to server /status api endpoint
|
||||||
*
|
*
|
||||||
* @param {string} address
|
* @param {string} address
|
||||||
* @returns {Promise<{isInit:boolean, language:string, authMethods:string[]}>}
|
* @returns {Promise<HttpResponse>}
|
||||||
|
* HttpResponse.data is {isInit:boolean, language:string, authMethods:string[]}>
|
||||||
*/
|
*/
|
||||||
getServerAddressStatus(address) {
|
async getServerAddressStatus(address) {
|
||||||
return this.getRequest(`${address}/status`).catch((error) => {
|
return this.getRequest(`${address}/status`)
|
||||||
console.error('Failed to get server status', error)
|
|
||||||
const errorMsg = error.message || error
|
|
||||||
this.error = 'Failed to ping server'
|
|
||||||
if (typeof errorMsg === 'string') {
|
|
||||||
this.error += ` (${errorMsg})`
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
pingServerAddress(address, customHeaders) {
|
pingServerAddress(address, customHeaders) {
|
||||||
return this.getRequest(`${address}/ping`, customHeaders)
|
return this.getRequest(`${address}/ping`, customHeaders)
|
||||||
.then((data) => {
|
.then((response) => {
|
||||||
return data.success
|
return response.data.success
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Server ping failed', error)
|
console.error('Server ping failed', error)
|
||||||
|
@ -353,31 +592,175 @@ export default {
|
||||||
async submit() {
|
async submit() {
|
||||||
if (!this.networkConnected) return
|
if (!this.networkConnected) return
|
||||||
if (!this.serverConfig.address) return
|
if (!this.serverConfig.address) return
|
||||||
if (!this.serverConfig.address.startsWith('http')) {
|
|
||||||
this.serverConfig.address = 'http://' + this.serverConfig.address
|
|
||||||
}
|
|
||||||
var validServerAddress = this.validateServerUrl(this.serverConfig.address)
|
|
||||||
if (!validServerAddress) {
|
|
||||||
this.error = 'Invalid server address'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.serverConfig.address = validServerAddress
|
const initialAddress = this.serverConfig.address
|
||||||
|
// Did the user specify a protocol?
|
||||||
|
const protocolProvided = initialAddress.startsWith('http://') || initialAddress.startsWith('https://')
|
||||||
|
// Add https:// if not provided
|
||||||
|
this.serverConfig.address = this.prependProtocolIfNeeded(initialAddress)
|
||||||
|
|
||||||
this.processing = true
|
this.processing = true
|
||||||
this.error = null
|
this.error = null
|
||||||
this.authMethods = []
|
this.authMethods = []
|
||||||
|
|
||||||
const statusData = await this.getServerAddressStatus(this.serverConfig.address)
|
try {
|
||||||
this.processing = false
|
// Try the server URL. If it fails and the protocol was not provided, try with http instead of https
|
||||||
if (statusData) {
|
const statusData = await this.tryServerUrl(this.serverConfig.address, !protocolProvided)
|
||||||
if (!statusData.isInit) {
|
if (this.validateLoginFormResponse(statusData, this.serverConfig.address, protocolProvided)) {
|
||||||
this.error = 'Server is not initialized'
|
|
||||||
} else {
|
|
||||||
this.showAuth = true
|
this.showAuth = true
|
||||||
this.authMethods = statusData.authMethods || []
|
this.authMethods = statusData.data.authMethods || []
|
||||||
|
this.oauth.buttonText = statusData.data.authFormData?.authOpenIDButtonText || 'Login with OpenID'
|
||||||
|
|
||||||
|
if (statusData.data.authFormData?.authOpenIDAutoLaunch) {
|
||||||
|
this.clickLoginWithOpenId()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.handleLoginFormError(error)
|
||||||
|
} finally {
|
||||||
|
this.processing = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
/** Validates the login form response from the server.
|
||||||
|
*
|
||||||
|
* Ensure the request has not been redirected to an unexpected hostname and check if it is Audiobookshelf
|
||||||
|
*
|
||||||
|
* @param {object} statusData - The data received from the server's response, including data and url.
|
||||||
|
* @param {string} initialAddressWithProtocol - The initial server address including the protocol used for the request.
|
||||||
|
* @param {boolean} protocolProvided - Indicates whether the protocol was explicitly provided in the initial address.
|
||||||
|
*
|
||||||
|
* @returns {boolean} - Returns `true` if the response is valid, otherwise `false` and sets this.error.
|
||||||
|
*/
|
||||||
|
validateLoginFormResponse(statusData, initialAddressWithProtocol, protocolProvided) {
|
||||||
|
// We have a 200 status code at this point
|
||||||
|
|
||||||
|
// Check if we got redirected to a different hostname, we don't allow this
|
||||||
|
const initialAddressUrl = new URL(initialAddressWithProtocol)
|
||||||
|
const currentAddressUrl = new URL(statusData.url)
|
||||||
|
if (initialAddressUrl.hostname !== currentAddressUrl.hostname) {
|
||||||
|
this.error = `Server redirected somewhere else (to ${currentAddressUrl.hostname})`
|
||||||
|
console.error(`[ServerConnectForm] Server redirected somewhere else (to ${currentAddressUrl.hostname})`)
|
||||||
|
return false
|
||||||
|
} // We don't allow a redirection back from https to http if the user used https:// explicitly
|
||||||
|
else if (protocolProvided && initialAddressWithProtocol.startsWith('https://') && currentAddressUrl.protocol === 'http') {
|
||||||
|
this.error = `You specified https:// but the Server redirected back to plain http`
|
||||||
|
console.error(`[ServerConnectForm] User specified https:// but server redirected to http`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check content of response now
|
||||||
|
if (!statusData || !statusData.data || Object.keys(statusData).length === 0) {
|
||||||
|
this.error = 'Response from server was empty' // Usually some kind of config error on server side
|
||||||
|
console.error('[ServerConnectForm] Received empty response')
|
||||||
|
return false
|
||||||
|
} else if (!('isInit' in statusData.data) || !('language' in statusData.data)) {
|
||||||
|
this.error = 'This does not seem to be a Audiobookshelf server'
|
||||||
|
console.error('[ServerConnectForm] Received as response from Server:\n', statusData)
|
||||||
|
return false
|
||||||
|
// TODO: delete the if above and comment the ones below out, as soon as the backend is ready to introduce a version check
|
||||||
|
// } else if (!('app' in statusData.data) || statusData.data.app.toLowerCase() !== 'audiobookshelf') {
|
||||||
|
// this.error = 'This does not seem to be a Audiobookshelf server'
|
||||||
|
// console.error('[ServerConnectForm] Received as response from Server:\n', statusData)
|
||||||
|
// return false
|
||||||
|
// } else if (!this.isValidVersion(statusData.data.serverVersion, requiredServerVersion)) {
|
||||||
|
// this.error = `Server version is below minimum required version of ${requiredServerVersion} (${statusData.data.serverVersion})`
|
||||||
|
// console.error('[ServerConnectForm] Server version is too low: ', statusData.data.serverVersion)
|
||||||
|
// return false
|
||||||
|
} else if (!statusData.data.isInit) {
|
||||||
|
this.error = 'Server is not initialized'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we got redirected from http to https, we allow this
|
||||||
|
// Also there is the possibility that https was tried (with protocolProvided false) but only http was successfull
|
||||||
|
// So set the correct protocol for the config
|
||||||
|
const configUrl = new URL(this.serverConfig.address)
|
||||||
|
configUrl.protocol = currentAddressUrl.protocol
|
||||||
|
this.serverConfig.address = configUrl.toString()
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Handles errors received during the login form process, providing user-friendly error messages.
|
||||||
|
*
|
||||||
|
* @param {Object} error - The error object received from a failed login attempt.
|
||||||
|
*/
|
||||||
|
handleLoginFormError(error) {
|
||||||
|
console.error('[ServerConnectForm] Received invalid status', error)
|
||||||
|
|
||||||
|
if (error.code === 404) {
|
||||||
|
this.error = `This does not seem to be an Audiobookshelf server. (Error: 404 querying /status)`
|
||||||
|
} else if (typeof error.code === 'number') {
|
||||||
|
// Error with HTTP Code
|
||||||
|
this.error = `Failed to retrieve status of server: ${error.code}`
|
||||||
|
} else {
|
||||||
|
// error is usually a meaningful error like "Server timed out"
|
||||||
|
this.error = `Failed to contact server. (${error})`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Attempts to retrieve the server address status for the given URL.
|
||||||
|
* If the initial attempt fails, it retries with HTTP if allowed.
|
||||||
|
*
|
||||||
|
* @param {string} address - The URL address to validate and check.
|
||||||
|
* @param {boolean} shouldRetryWithHttp - Flag to indicate if the function should retry with HTTP on failure.
|
||||||
|
* @returns {Promise<HttpResponse>}
|
||||||
|
* HttpResponse.data is {isInit:boolean, language:string, authMethods:string[]}>
|
||||||
|
* @throws Will throw an error if the URL has a wrong format or if both HTTPS and HTTP (if retried) requests fail.
|
||||||
|
*/
|
||||||
|
async tryServerUrl(address, shouldRetryWithHttp) {
|
||||||
|
const validatedUrl = this.validateServerUrl(address)
|
||||||
|
if (!validatedUrl) {
|
||||||
|
throw new Error('URL has wrong format')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.getServerAddressStatus(validatedUrl)
|
||||||
|
} catch (error) {
|
||||||
|
// We only retry when the user did not specify a protocol
|
||||||
|
// Also for security reasons, we only retry when the https request did not
|
||||||
|
// return a http status code (so only retry when the TCP connection could not be established)
|
||||||
|
if (shouldRetryWithHttp && typeof error.code !== 'number') {
|
||||||
|
console.log('[ServerConnectForm] https failed, trying to connect with http...')
|
||||||
|
const validatedHttpUrl = this.validateServerUrl(address, 'http:')
|
||||||
|
if (validatedHttpUrl) {
|
||||||
|
return await this.getServerAddressStatus(validatedHttpUrl)
|
||||||
|
}
|
||||||
|
// else if validatedHttpUrl is false return the original error below
|
||||||
|
}
|
||||||
|
// rethrow original error
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Ensures that a protocol is prepended to the given address if it does not already start with http:// or https://.
|
||||||
|
*
|
||||||
|
* @param {string} address - The server address that may or may not have a protocol.
|
||||||
|
* @returns {string} The address with a protocol prepended if it was missing.
|
||||||
|
*/
|
||||||
|
prependProtocolIfNeeded(address) {
|
||||||
|
return address.startsWith('http://') || address.startsWith('https://') ? address : `https://${address}`
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Compares two semantic versioning strings to determine if the current version meets
|
||||||
|
* or exceeds the minimum version requirement.
|
||||||
|
*
|
||||||
|
* @param {string} currentVersion - The current version string to compare, e.g., "1.2.3".
|
||||||
|
* @param {string} minVersion - The minimum version string required, e.g., "1.0.0".
|
||||||
|
* @returns {boolean} - Returns true if the current version is greater than or equal
|
||||||
|
* to the minimum version, false otherwise.
|
||||||
|
*/
|
||||||
|
isValidVersion(currentVersion, minVersion) {
|
||||||
|
const currentParts = currentVersion.split('.').map(Number)
|
||||||
|
const minParts = minVersion.split('.').map(Number)
|
||||||
|
|
||||||
|
for (let i = 0; i < minParts.length; i++) {
|
||||||
|
if (currentParts[i] > minParts[i]) return true
|
||||||
|
if (currentParts[i] < minParts[i]) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
async submitAuth() {
|
async submitAuth() {
|
||||||
if (!this.networkConnected) return
|
if (!this.networkConnected) return
|
||||||
if (!this.serverConfig.username) {
|
if (!this.serverConfig.username) {
|
||||||
|
@ -438,7 +821,7 @@ export default {
|
||||||
this.error = null
|
this.error = null
|
||||||
this.processing = true
|
this.processing = true
|
||||||
|
|
||||||
const authRes = await this.postRequest(`${this.serverConfig.address}/api/authorize`, null, { Authorization: `Bearer ${this.serverConfig.token}` }).catch((error) => {
|
const authRes = await this.postRequest(`${this.serverConfig.address}api/authorize`, null, { Authorization: `Bearer ${this.serverConfig.token}` }).catch((error) => {
|
||||||
console.error('[ServerConnectForm] Server auth failed', error)
|
console.error('[ServerConnectForm] Server auth failed', error)
|
||||||
const errorMsg = error.message || error
|
const errorMsg = error.message || error
|
||||||
this.error = 'Failed to authorize'
|
this.error = 'Failed to authorize'
|
||||||
|
@ -453,7 +836,7 @@ export default {
|
||||||
this.processing = false
|
this.processing = false
|
||||||
return authRes
|
return authRes
|
||||||
},
|
},
|
||||||
async init() {
|
init() {
|
||||||
if (this.lastServerConnectionConfig) {
|
if (this.lastServerConnectionConfig) {
|
||||||
this.connectToServer(this.lastServerConnectionConfig)
|
this.connectToServer(this.lastServerConnectionConfig)
|
||||||
} else {
|
} else {
|
||||||
|
@ -462,7 +845,11 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
this.$eventBus.$on('url-open', this.appUrlOpen)
|
||||||
this.init()
|
this.init()
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.$eventBus.$off('url-open', this.appUrlOpen)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -2,6 +2,17 @@
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>com.audiobookshelf.app</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>audiobookshelf</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>en</string>
|
<string>en</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
|
|
|
@ -11,8 +11,8 @@ install! 'cocoapods', :disable_input_output_paths => true
|
||||||
def capacitor_pods
|
def capacitor_pods
|
||||||
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
|
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
|
||||||
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
|
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
|
||||||
pod 'ByteowlsCapacitorOauth2', :path => '../../node_modules/@byteowls/capacitor-oauth2'
|
|
||||||
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
|
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
|
||||||
|
pod 'CapacitorBrowser', :path => '../../node_modules/@capacitor/browser'
|
||||||
pod 'CapacitorClipboard', :path => '../../node_modules/@capacitor/clipboard'
|
pod 'CapacitorClipboard', :path => '../../node_modules/@capacitor/clipboard'
|
||||||
pod 'CapacitorDialog', :path => '../../node_modules/@capacitor/dialog'
|
pod 'CapacitorDialog', :path => '../../node_modules/@capacitor/dialog'
|
||||||
pod 'CapacitorHaptics', :path => '../../node_modules/@capacitor/haptics'
|
pod 'CapacitorHaptics', :path => '../../node_modules/@capacitor/haptics'
|
||||||
|
|
|
@ -1,23 +1,25 @@
|
||||||
PODS:
|
PODS:
|
||||||
- Alamofire (5.6.4)
|
- Alamofire (5.6.4)
|
||||||
- Capacitor (4.8.0):
|
- Capacitor (5.4.0):
|
||||||
- CapacitorCordova
|
- CapacitorCordova
|
||||||
- CapacitorApp (4.1.1):
|
- CapacitorApp (5.0.6):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapacitorClipboard (4.1.0):
|
- CapacitorBrowser (5.1.0):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapacitorCordova (4.8.0)
|
- CapacitorClipboard (5.0.6):
|
||||||
- CapacitorDialog (4.1.0):
|
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapacitorHaptics (4.1.0):
|
- CapacitorCordova (5.4.0)
|
||||||
|
- CapacitorDialog (5.0.6):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapacitorNetwork (4.1.0):
|
- CapacitorHaptics (5.0.6):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapacitorPreferences (4.0.2):
|
- CapacitorNetwork (5.0.6):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CapacitorStatusBar (4.1.1):
|
- CapacitorPreferences (5.0.6):
|
||||||
- Capacitor
|
- Capacitor
|
||||||
- CordovaPlugins (4.8.0):
|
- CapacitorStatusBar (5.0.6):
|
||||||
|
- Capacitor
|
||||||
|
- CordovaPlugins (5.4.0):
|
||||||
- CapacitorCordova
|
- CapacitorCordova
|
||||||
- Realm (10.36.0):
|
- Realm (10.36.0):
|
||||||
- Realm/Headers (= 10.36.0)
|
- Realm/Headers (= 10.36.0)
|
||||||
|
@ -29,6 +31,7 @@ DEPENDENCIES:
|
||||||
- Alamofire (~> 5.5)
|
- Alamofire (~> 5.5)
|
||||||
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
|
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
|
||||||
- "CapacitorApp (from `../../node_modules/@capacitor/app`)"
|
- "CapacitorApp (from `../../node_modules/@capacitor/app`)"
|
||||||
|
- "CapacitorBrowser (from `../../node_modules/@capacitor/browser`)"
|
||||||
- "CapacitorClipboard (from `../../node_modules/@capacitor/clipboard`)"
|
- "CapacitorClipboard (from `../../node_modules/@capacitor/clipboard`)"
|
||||||
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
|
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
|
||||||
- "CapacitorDialog (from `../../node_modules/@capacitor/dialog`)"
|
- "CapacitorDialog (from `../../node_modules/@capacitor/dialog`)"
|
||||||
|
@ -50,6 +53,8 @@ EXTERNAL SOURCES:
|
||||||
:path: "../../node_modules/@capacitor/ios"
|
:path: "../../node_modules/@capacitor/ios"
|
||||||
CapacitorApp:
|
CapacitorApp:
|
||||||
:path: "../../node_modules/@capacitor/app"
|
:path: "../../node_modules/@capacitor/app"
|
||||||
|
CapacitorBrowser:
|
||||||
|
:path: "../../node_modules/@capacitor/browser"
|
||||||
CapacitorClipboard:
|
CapacitorClipboard:
|
||||||
:path: "../../node_modules/@capacitor/clipboard"
|
:path: "../../node_modules/@capacitor/clipboard"
|
||||||
CapacitorCordova:
|
CapacitorCordova:
|
||||||
|
@ -69,19 +74,20 @@ EXTERNAL SOURCES:
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
Alamofire: 4e95d97098eacb88856099c4fc79b526a299e48c
|
Alamofire: 4e95d97098eacb88856099c4fc79b526a299e48c
|
||||||
Capacitor: 6002aadd64492438e5242325025045235dcb7e84
|
Capacitor: a5cd803e02b471591c81165f400ace01f40b11d3
|
||||||
CapacitorApp: acd42fe8561fe751ad5b5f459aa85e6acd7bee24
|
CapacitorApp: 024e1b1bea5f883d79f6330d309bc441c88ad04a
|
||||||
CapacitorClipboard: 4c092a2608520afb799429e63820256c3a59f1e5
|
CapacitorBrowser: 7a0fb6a1011abfaaf2dfedfd8248f942a8eda3d6
|
||||||
CapacitorCordova: c6249dcb2cf04dd835c0e99df1df4b9c8ad997e2
|
CapacitorClipboard: 77edf49827ea21da2a9c05c690a4a6a4d07199c4
|
||||||
CapacitorDialog: c8a6558d29767e76a32a056bb5e0fc9104b985b0
|
CapacitorCordova: 66ce22f9976de30fd816f746e9e92e07d6befafd
|
||||||
CapacitorHaptics: 213b3a1f3efd6dbf6e6b76a1b2bb0399cf43b213
|
CapacitorDialog: 0f3c15dfe9414b83bc64aef4078f1b92bcfead26
|
||||||
CapacitorNetwork: 7126b3d2d23ca60d5ac0d8d2ecccfab0b1f305c6
|
CapacitorHaptics: 1fffc1217c7e64a472d7845be50fb0c2f7d4204c
|
||||||
CapacitorPreferences: 1d66dc32299f55ed632c5611f312878979275ea5
|
CapacitorNetwork: d80b3e79bef6ec37640ee2806c19771f07ff2d0c
|
||||||
CapacitorStatusBar: 65933e554bb5d65b361deaa936a93616086a2608
|
CapacitorPreferences: f03954bcb0ff09c792909e46bff88e3183c16b10
|
||||||
CordovaPlugins: b7ac282a1681fad663e14dcbe719249f738b88ce
|
CapacitorStatusBar: 565c0a1ebd79bb40d797606a8992b4a105885309
|
||||||
|
CordovaPlugins: a5db67e5ac1061b9869a0efd754f2c2f776aeccc
|
||||||
Realm: 3fd136cb4c83a927482a7f1612496d37beed3cf5
|
Realm: 3fd136cb4c83a927482a7f1612496d37beed3cf5
|
||||||
RealmSwift: 513d4dcbf5bfc4d573454088b592685fc48dd716
|
RealmSwift: 513d4dcbf5bfc4d573454088b592685fc48dd716
|
||||||
|
|
||||||
PODFILE CHECKSUM: 05c80969578f3260e71d903c6ddb969847bcceb2
|
PODFILE CHECKSUM: 7a8fc177ef0646dd60a1ee8aa387964975fcc1e3
|
||||||
|
|
||||||
COCOAPODS: 1.12.1
|
COCOAPODS: 1.12.0
|
||||||
|
|
32
package-lock.json
generated
32
package-lock.json
generated
|
@ -8,9 +8,9 @@
|
||||||
"name": "audiobookshelf-app",
|
"name": "audiobookshelf-app",
|
||||||
"version": "0.9.66-beta",
|
"version": "0.9.66-beta",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@byteowls/capacitor-oauth2": "^5.0.0",
|
|
||||||
"@capacitor/android": "^5.0.0",
|
"@capacitor/android": "^5.0.0",
|
||||||
"@capacitor/app": "^5.0.0",
|
"@capacitor/app": "^5.0.6",
|
||||||
|
"@capacitor/browser": "^5.1.0",
|
||||||
"@capacitor/clipboard": "^5.0.0",
|
"@capacitor/clipboard": "^5.0.0",
|
||||||
"@capacitor/core": "^5.0.0",
|
"@capacitor/core": "^5.0.0",
|
||||||
"@capacitor/dialog": "^5.0.0",
|
"@capacitor/dialog": "^5.0.0",
|
||||||
|
@ -1939,14 +1939,6 @@
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@byteowls/capacitor-oauth2": {
|
|
||||||
"version": "5.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@byteowls/capacitor-oauth2/-/capacitor-oauth2-5.0.0.tgz",
|
|
||||||
"integrity": "sha512-yW50GypmyPJcH/95NwR2jJcgT78vBN3FYKL2w6A3vrT04bRLQyw2K0fLqfj8Zws6DJy43Ck1wPs0Bcdvbsub7A==",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@capacitor/core": ">=5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@capacitor/android": {
|
"node_modules/@capacitor/android": {
|
||||||
"version": "5.4.0",
|
"version": "5.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-5.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-5.4.0.tgz",
|
||||||
|
@ -1963,6 +1955,14 @@
|
||||||
"@capacitor/core": "^5.0.0"
|
"@capacitor/core": "^5.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@capacitor/browser": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@capacitor/browser/-/browser-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-7togqchk2Tvq4SmLaWhcrd4x48ES/GEZsceM+29aun7WhxQEVcDU0cJsVdSU2LNFwNhWgPV2GW90etVd1B3OdQ==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@capacitor/core": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@capacitor/cli": {
|
"node_modules/@capacitor/cli": {
|
||||||
"version": "5.4.0",
|
"version": "5.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-5.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-5.4.0.tgz",
|
||||||
|
@ -21078,12 +21078,6 @@
|
||||||
"to-fast-properties": "^2.0.0"
|
"to-fast-properties": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@byteowls/capacitor-oauth2": {
|
|
||||||
"version": "5.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@byteowls/capacitor-oauth2/-/capacitor-oauth2-5.0.0.tgz",
|
|
||||||
"integrity": "sha512-yW50GypmyPJcH/95NwR2jJcgT78vBN3FYKL2w6A3vrT04bRLQyw2K0fLqfj8Zws6DJy43Ck1wPs0Bcdvbsub7A==",
|
|
||||||
"requires": {}
|
|
||||||
},
|
|
||||||
"@capacitor/android": {
|
"@capacitor/android": {
|
||||||
"version": "5.4.0",
|
"version": "5.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-5.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-5.4.0.tgz",
|
||||||
|
@ -21096,6 +21090,12 @@
|
||||||
"integrity": "sha512-6ZXVdnNmaYILasC/RjQw+yfTmq2ZO7Q3v5lFcDVfq3PFGnybyYQh+RstBrYri+376OmXOXxBD7E6UxBhrMzXGA==",
|
"integrity": "sha512-6ZXVdnNmaYILasC/RjQw+yfTmq2ZO7Q3v5lFcDVfq3PFGnybyYQh+RstBrYri+376OmXOXxBD7E6UxBhrMzXGA==",
|
||||||
"requires": {}
|
"requires": {}
|
||||||
},
|
},
|
||||||
|
"@capacitor/browser": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@capacitor/browser/-/browser-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-7togqchk2Tvq4SmLaWhcrd4x48ES/GEZsceM+29aun7WhxQEVcDU0cJsVdSU2LNFwNhWgPV2GW90etVd1B3OdQ==",
|
||||||
|
"requires": {}
|
||||||
|
},
|
||||||
"@capacitor/cli": {
|
"@capacitor/cli": {
|
||||||
"version": "5.4.0",
|
"version": "5.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-5.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-5.4.0.tgz",
|
||||||
|
|
|
@ -13,9 +13,9 @@
|
||||||
"ionic:serve": "npm run start"
|
"ionic:serve": "npm run start"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@byteowls/capacitor-oauth2": "^5.0.0",
|
|
||||||
"@capacitor/android": "^5.0.0",
|
"@capacitor/android": "^5.0.0",
|
||||||
"@capacitor/app": "^5.0.0",
|
"@capacitor/app": "^5.0.6",
|
||||||
|
"@capacitor/browser": "^5.1.0",
|
||||||
"@capacitor/clipboard": "^5.0.0",
|
"@capacitor/clipboard": "^5.0.0",
|
||||||
"@capacitor/core": "^5.0.0",
|
"@capacitor/core": "^5.0.0",
|
||||||
"@capacitor/dialog": "^5.0.0",
|
"@capacitor/dialog": "^5.0.0",
|
||||||
|
|
|
@ -282,6 +282,14 @@ export default ({ store, app }, inject) => {
|
||||||
window.history.back()
|
window.history.back()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see https://capacitorjs.com/docs/apis/app#addlistenerappurlopen-
|
||||||
|
* Listen for url open events for the app. This handles both custom URL scheme links as well as URLs your app handles
|
||||||
|
*/
|
||||||
|
App.addListener('appUrlOpen', (data) => {
|
||||||
|
eventBus.$emit('url-open', data.url)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue