diff --git a/sdk/base/lib/util/getServiceInterface.ts b/sdk/base/lib/util/getServiceInterface.ts index fa21db335..2fab5c7cf 100644 --- a/sdk/base/lib/util/getServiceInterface.ts +++ b/sdk/base/lib/util/getServiceInterface.ts @@ -5,6 +5,7 @@ import { Effects } from "../Effects" import { DropGenerator, DropPromise } from "./Drop" import { IpAddress, IPV6_LINK_LOCAL } from "./ip" import { deepEqual } from "./deepEqual" +import { once } from "./once" export type UrlString = string export type HostId = string @@ -255,6 +256,21 @@ export const filledAddress = ( function filledAddressFromHostnames( hostnames: HostnameInfo[], ): Filled & AddressInfo { + const getNonLocal = once(() => + filledAddressFromHostnames( + filterRec(hostnames, nonLocalFilter, false), + ), + ) + const getPublic = once(() => + filledAddressFromHostnames( + filterRec(hostnames, publicFilter, false), + ), + ) + const getOnion = once(() => + filledAddressFromHostnames( + filterRec(hostnames, onionFilter, false), + ), + ) return { ...addressInfo, hostnames, @@ -273,19 +289,13 @@ export const filledAddress = ( ) }, get nonLocal(): Filled { - return filledAddressFromHostnames( - filterRec(hostnames, nonLocalFilter, false), - ) + return getNonLocal() }, get public(): Filled { - return filledAddressFromHostnames( - filterRec(hostnames, publicFilter, false), - ) + return getPublic() }, get onion(): Filled { - return filledAddressFromHostnames( - filterRec(hostnames, onionFilter, false), - ) + return getOnion() }, } } diff --git a/sdk/base/lib/util/index.ts b/sdk/base/lib/util/index.ts index c4c900192..25c3938f9 100644 --- a/sdk/base/lib/util/index.ts +++ b/sdk/base/lib/util/index.ts @@ -4,7 +4,11 @@ export { getDefaultString } from "./getDefaultString" export * from "./ip" /// Not being used, but known to be browser compatible -export { GetServiceInterface, getServiceInterface } from "./getServiceInterface" +export { + GetServiceInterface, + getServiceInterface, + filledAddress, +} from "./getServiceInterface" export { getServiceInterfaces } from "./getServiceInterfaces" export { once } from "./once" export { asError } from "./asError" diff --git a/web/projects/shared/src/types/workspace-config.ts b/web/projects/shared/src/types/workspace-config.ts index 7ccd1bec4..5f5f6601d 100644 --- a/web/projects/shared/src/types/workspace-config.ts +++ b/web/projects/shared/src/types/workspace-config.ts @@ -1,3 +1,12 @@ +export type AccessType = + | 'tor' + | 'mdns' + | 'localhost' + | 'ipv4' + | 'ipv6' + | 'domain' + | 'wan-ipv4' + export type WorkspaceConfig = { gitHash: string useMocks: boolean @@ -8,7 +17,7 @@ export type WorkspaceConfig = { version: string } mocks: { - maskAs: 'tor' | 'local' | 'localhost' | 'ipv4' | 'ipv6' | 'clearnet' + maskAs: AccessType maskAsHttps: boolean skipStartupAlerts: boolean } diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.ts index 9dbe83214..65e3055bb 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.ts @@ -206,119 +206,67 @@ export class InterfaceService { /** ${scheme}://${username}@${host}:${externalPort}${suffix} */ launchableAddress(ui: T.ServiceInterface, host: T.Host): string { - const hostnameInfos = this.hostnameInfo(ui, host) + const addresses = utils.filledAddress(host, ui.addressInfo) - if (!hostnameInfos.length) return '' + if (!addresses.hostnames.length) return '' - const addressInfo = ui.addressInfo - const username = addressInfo.username ? addressInfo.username + '@' : '' - const suffix = addressInfo.suffix || '' - const url = new URL(`https://${username}placeholder${suffix}`) - const use = (hostname: { - value: string - port: number | null - sslPort: number | null - }) => { - url.hostname = hostname.value - const useSsl = - hostname.port && hostname.sslPort - ? this.config.isHttps() - : !!hostname.sslPort - url.protocol = useSsl - ? `${addressInfo.sslScheme || 'https'}:` - : `${addressInfo.scheme || 'http'}:` - const port = useSsl ? hostname.sslPort : hostname.port - const omitPort = useSsl - ? ui.addressInfo.sslScheme === 'https' && port === 443 - : ui.addressInfo.scheme === 'http' && port === 80 - if (!omitPort && port) url.port = String(port) - } - const useFirst = ( - hostnames: ( - | { - value: string - port: number | null - sslPort: number | null - } - | undefined - )[], - ) => { - const first = hostnames.find(h => h) - if (first) { - use(first) - } - return !!first + const publicDomains = addresses.filter({ + kind: 'domain', + visibility: 'public', + }) + const tor = addresses.filter({ kind: 'onion' }) + const wanIp = addresses.filter({ kind: 'ipv4', visibility: 'public' }) + const bestPublic = [publicDomains, tor, wanIp].flatMap(h => + h.format('urlstring'), + )[0] + const privateDomains = addresses.filter({ + kind: 'domain', + visibility: 'private', + }) + const mdns = addresses.filter({ kind: 'mdns' }) + const bestPrivate = [privateDomains, mdns].flatMap(h => + h.format('urlstring'), + )[0] + + let matching + let onLan = false + switch (this.config.accessType) { + case 'ipv4': + matching = addresses.nonLocal + .filter({ + kind: 'ipv4', + predicate: h => h.hostname.value === this.config.hostname, + }) + .format('urlstring')[0] + onLan = true + break + case 'ipv6': + matching = addresses.nonLocal + .filter({ + kind: 'ipv6', + predicate: h => h.hostname.value === this.config.hostname, + }) + .format('urlstring')[0] + break + case 'localhost': + matching = addresses + .filter({ kind: 'localhost' }) + .format('urlstring')[0] + onLan = true + break + case 'tor': + matching = tor.format('urlstring')[0] + break + case 'mdns': + matching = mdns.format('urlstring')[0] + onLan = true + break } - const ipHostnames = hostnameInfos - .filter(h => h.kind === 'ip') - .map(h => h.hostname) as T.IpHostname[] - const domainHostname = ipHostnames - .filter(h => h.kind === 'domain') - .map(h => h as T.IpHostname & { kind: 'domain' }) - .map(h => ({ - value: h.value, - sslPort: h.sslPort, - port: h.port, - }))[0] - const wanIpHostname = hostnameInfos - .filter(h => h.kind === 'ip' && h.public && h.hostname.kind !== 'domain') - .map(h => h.hostname as Exclude) - .map(h => ({ - value: h.value, - sslPort: h.sslPort, - port: h.port, - }))[0] - const onionHostname = hostnameInfos - .filter(h => h.kind === 'onion') - .map(h => h as T.HostnameInfo & { kind: 'onion' }) - .map(h => ({ - value: h.hostname.value, - sslPort: h.hostname.sslPort, - port: h.hostname.port, - }))[0] - const localHostname = ipHostnames - .filter(h => h.kind === 'local') - .map(h => h as T.IpHostname & { kind: 'local' }) - .map(h => ({ value: h.value, sslPort: h.sslPort, port: h.port }))[0] - - if (this.config.isClearnet()) { - if ( - !useFirst([domainHostname, wanIpHostname, onionHostname, localHostname]) - ) { - return '' - } - } else if (this.config.isTor()) { - if ( - !useFirst([onionHostname, domainHostname, wanIpHostname, localHostname]) - ) { - return '' - } - } else if (this.config.isIpv6()) { - const ipv6Hostname = ipHostnames.find(h => h.kind === 'ipv6') as { - kind: 'ipv6' - value: string - scopeId: number - port: number | null - sslPort: number | null - } - - if (!useFirst([ipv6Hostname, localHostname])) { - return '' - } - } else { - // ipv4 or .local or localhost - - if (!localHostname) return '' - - use({ - value: this.config.hostname, - port: localHostname.port, - sslPort: localHostname.sslPort, - }) - } - - return url.href + if (matching) return matching + if (onLan && bestPrivate) return bestPrivate + if (bestPublic) return bestPublic + return '' } private hostnameInfo( @@ -330,7 +278,7 @@ export class InterfaceService { return ( hostnameInfo?.filter( h => - this.config.isLocalhost() || + this.config.accessType === 'localhost' || !( h.kind === 'ip' && ((h.hostname.kind === 'ipv6' && diff --git a/web/projects/ui/src/app/routes/portal/routes/sideload/sideload.component.ts b/web/projects/ui/src/app/routes/portal/routes/sideload/sideload.component.ts index 3e0fec756..8f2055787 100644 --- a/web/projects/ui/src/app/routes/portal/routes/sideload/sideload.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/sideload/sideload.component.ts @@ -92,7 +92,7 @@ import { MarketplacePkgSideload, validateS9pk } from './sideload.utils' ], }) export default class SideloadComponent { - readonly isTor = inject(ConfigService).isTor() + readonly isTor = inject(ConfigService).accessType === 'tor' file: File | null = null readonly package = signal(null) diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/general/general.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/general/general.component.ts index 4ca6527af..14cc377fd 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/general/general.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/general/general.component.ts @@ -245,7 +245,7 @@ export default class SystemGeneralComponent { private readonly errorService = inject(ErrorService) private readonly patch = inject>(PatchDB) private readonly api = inject(ApiService) - private readonly isTor = inject(ConfigService).isTor() + private readonly isTor = inject(ConfigService).accessType === 'tor' private readonly dialog = inject(DialogService) private readonly i18n = inject(i18nPipe) private readonly injector = inject(INJECTOR) diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/general/wipe.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/general/wipe.component.ts index f37a07696..0456cbed3 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/general/wipe.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/general/wipe.component.ts @@ -31,6 +31,6 @@ import { i18nPipe } from '@start9labs/shared' imports: [TuiLabel, FormsModule, TuiCheckbox, i18nPipe], }) export class SystemWipeComponent { - readonly isTor = inject(ConfigService).isTor() + readonly isTor = inject(ConfigService).accessType === 'tor' readonly component = inject(SystemGeneralComponent) } diff --git a/web/projects/ui/src/app/services/api/api.fixures.ts b/web/projects/ui/src/app/services/api/api.fixures.ts index a723df0be..117e50ef1 100644 --- a/web/projects/ui/src/app/services/api/api.fixures.ts +++ b/web/projects/ui/src/app/services/api/api.fixures.ts @@ -242,6 +242,7 @@ export namespace Mock { arch: null, ram: null, }, + hardwareAcceleration: false, } export const MockManifestLnd: T.Manifest = { @@ -300,6 +301,7 @@ export namespace Mock { arch: null, ram: null, }, + hardwareAcceleration: false, } export const MockManifestBitcoinProxy: T.Manifest = { @@ -351,6 +353,7 @@ export namespace Mock { arch: null, ram: null, }, + hardwareAcceleration: false, } export const BitcoinDep: T.DependencyMetadata = { diff --git a/web/projects/ui/src/app/services/config.service.ts b/web/projects/ui/src/app/services/config.service.ts index d28d7b157..3e9097b54 100644 --- a/web/projects/ui/src/app/services/config.service.ts +++ b/web/projects/ui/src/app/services/config.service.ts @@ -1,5 +1,5 @@ import { Inject, Injectable, DOCUMENT } from '@angular/core' -import { WorkspaceConfig } from '@start9labs/shared' +import { AccessType, WorkspaceConfig } from '@start9labs/shared' import { T, utils } from '@start9labs/start-sdk' const { @@ -29,53 +29,29 @@ export class ConfigService { supportsWebSockets = !!window.WebSocket defaultRegistry = defaultRegistry - isTor(): boolean { - return useMocks ? mocks.maskAs === 'tor' : this.hostname.endsWith('.onion') - } - - isLocalhost(): boolean { - return useMocks - ? mocks.maskAs === 'localhost' - : this.hostname === 'localhost' || this.hostname === '127.0.0.1' + private getAccessType = utils.once(() => { + if (useMocks) return mocks.maskAs + if (this.hostname === 'localhost') return 'localhost' + if (this.hostname.endsWith('.onion')) return 'tor' + if (this.hostname.endsWith('.local')) return 'mdns' + let ip = null + try { + ip = utils.IpAddress.parse(this.hostname.replace(/[\[\]]/g, '')) + } catch {} + if (ip) { + if (utils.IPV4_LOOPBACK.contains(ip) || utils.IPV6_LOOPBACK.contains(ip)) + return 'localhost' + if (ip.isIpv4()) return ip.isPublic() ? 'wan-ipv4' : 'ipv4' + return 'ipv6' + } + return 'domain' + }) + get accessType(): AccessType { + return this.getAccessType() } isLanHttp(): boolean { - return !this.isTor() && !this.isLocalhost() && !this.isHttps() - } - - private isLocal(): boolean { - return useMocks - ? mocks.maskAs === 'local' - : this.hostname.endsWith('.local') - } - - private isLanIpv4(): boolean { - return useMocks - ? mocks.maskAs === 'ipv4' - : new RegExp(utils.Patterns.ipv4.regex).test(this.hostname) && - (this.hostname.startsWith('192.168.') || - this.hostname.startsWith('10.') || - (this.hostname.startsWith('172.') && - !![this.hostname.split('.').map(Number)[1] || NaN].filter( - n => n >= 16 && n < 32, - ).length)) - } - - isIpv6(): boolean { - return useMocks - ? mocks.maskAs === 'ipv6' - : new RegExp(utils.Patterns.ipv6.regex).test(this.hostname) - } - - isClearnet(): boolean { - return useMocks - ? mocks.maskAs === 'clearnet' - : this.isHttps() && - !this.isTor() && - !this.isLocal() && - !this.isLocalhost() && - !this.isLanIpv4() && - !this.isIpv6() + return !this.isHttps() && !['localhost', 'tor'].includes(this.accessType) } isHttps(): boolean {