mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-31 12:33:40 +00:00
Compare commits
2 Commits
plugins
...
feat/gener
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8401b753fb | ||
|
|
f8efd6e6be |
24
.github/workflows/startos-iso.yaml
vendored
24
.github/workflows/startos-iso.yaml
vendored
@@ -89,9 +89,9 @@ jobs:
|
||||
"riscv64": "ubuntu-latest"
|
||||
}')[matrix.arch],
|
||||
fromJson('{
|
||||
"x86_64": "amd64-fast",
|
||||
"aarch64": "aarch64-fast",
|
||||
"riscv64": "amd64-fast"
|
||||
"x86_64": "ubuntu-24.04-32-cores",
|
||||
"aarch64": "ubuntu-24.04-arm-32-cores",
|
||||
"riscv64": "ubuntu-24.04-32-cores"
|
||||
}')[matrix.arch]
|
||||
)
|
||||
)[github.event.inputs.runner == 'fast']
|
||||
@@ -153,15 +153,15 @@ jobs:
|
||||
"riscv64-nonfree": "ubuntu-24.04-arm",
|
||||
}')[matrix.platform],
|
||||
fromJson('{
|
||||
"x86_64": "amd64-fast",
|
||||
"x86_64-nonfree": "amd64-fast",
|
||||
"x86_64-nvidia": "amd64-fast",
|
||||
"aarch64": "aarch64-fast",
|
||||
"aarch64-nonfree": "aarch64-fast",
|
||||
"aarch64-nvidia": "aarch64-fast",
|
||||
"raspberrypi": "aarch64-fast",
|
||||
"riscv64": "amd64-fast",
|
||||
"riscv64-nonfree": "amd64-fast",
|
||||
"x86_64": "ubuntu-24.04-8-cores",
|
||||
"x86_64-nonfree": "ubuntu-24.04-8-cores",
|
||||
"x86_64-nvidia": "ubuntu-24.04-8-cores",
|
||||
"aarch64": "ubuntu-24.04-arm-8-cores",
|
||||
"aarch64-nonfree": "ubuntu-24.04-arm-8-cores",
|
||||
"aarch64-nvidia": "ubuntu-24.04-arm-8-cores",
|
||||
"raspberrypi": "ubuntu-24.04-arm-8-cores",
|
||||
"riscv64": "ubuntu-24.04-8-cores",
|
||||
"riscv64-nonfree": "ubuntu-24.04-8-cores",
|
||||
}')[matrix.platform]
|
||||
)
|
||||
)[github.event.inputs.runner == 'fast']
|
||||
|
||||
@@ -494,7 +494,7 @@ export class SystemForEmbassy implements System {
|
||||
const host = new MultiHost({ effects, id })
|
||||
const internalPorts = new Set(
|
||||
Object.values(interfaceValue["tor-config"]?.["port-mapping"] ?? {})
|
||||
.map((v) => parseInt(v))
|
||||
.map(Number.parseInt)
|
||||
.concat(
|
||||
...Object.values(interfaceValue["lan-config"] ?? {}).map(
|
||||
(c) => c.internal,
|
||||
|
||||
@@ -134,7 +134,8 @@ pub async fn list_service_interfaces(
|
||||
.expect("valid json pointer");
|
||||
let mut watch = context.seed.ctx.db.watch(ptr).await;
|
||||
|
||||
let res = from_value(watch.peek_and_mark_seen()?)?;
|
||||
let res = imbl_value::from_value(watch.peek_and_mark_seen()?)
|
||||
.unwrap_or_default();
|
||||
|
||||
if let Some(callback) = callback {
|
||||
let callback = callback.register(&context.seed.persistent_container);
|
||||
@@ -173,7 +174,9 @@ pub async fn clear_service_interfaces(
|
||||
.as_idx_mut(&package_id)
|
||||
.or_not_found(&package_id)?
|
||||
.as_service_interfaces_mut()
|
||||
.mutate(|s| Ok(s.retain(|id, _| except.contains(id))))
|
||||
.mutate(|s| {
|
||||
Ok(s.retain(|id, _| except.contains(id)))
|
||||
})
|
||||
})
|
||||
.await
|
||||
.result?;
|
||||
|
||||
@@ -40,102 +40,6 @@ lazy_static::lazy_static! {
|
||||
);
|
||||
}
|
||||
|
||||
/// Detect the LC_COLLATE / LC_CTYPE the cluster was created with and generate
|
||||
/// those locales if they are missing from the running system. Older installs
|
||||
/// may have been initialized with a locale (e.g. en_GB.UTF-8) that the current
|
||||
/// image does not ship. Without it PostgreSQL starts but refuses
|
||||
/// connections, breaking the migration.
|
||||
async fn ensure_cluster_locale(pg_version: u32) -> Result<(), Error> {
|
||||
let cluster_dir = format!("/var/lib/postgresql/{pg_version}/main");
|
||||
let pg_controldata = format!("/usr/lib/postgresql/{pg_version}/bin/pg_controldata");
|
||||
|
||||
let output = Command::new(&pg_controldata)
|
||||
.arg(&cluster_dir)
|
||||
.kill_on_drop(true)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.output()
|
||||
.await
|
||||
.with_kind(crate::ErrorKind::Database)?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::warn!("pg_controldata failed, skipping locale check: {stderr}");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let mut locales_needed = Vec::new();
|
||||
for line in stdout.lines() {
|
||||
let locale = if let Some(rest) = line.strip_prefix("LC_COLLATE:") {
|
||||
rest.trim()
|
||||
} else if let Some(rest) = line.strip_prefix("LC_CTYPE:") {
|
||||
rest.trim()
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
if !locale.is_empty() && locale != "C" && locale != "POSIX" {
|
||||
locales_needed.push(locale.to_owned());
|
||||
}
|
||||
}
|
||||
locales_needed.sort();
|
||||
locales_needed.dedup();
|
||||
|
||||
if locales_needed.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check which locales are already available.
|
||||
let available = Command::new("locale")
|
||||
.arg("-a")
|
||||
.kill_on_drop(true)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.output()
|
||||
.await
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut need_gen = false;
|
||||
for locale in &locales_needed {
|
||||
// locale -a normalizes e.g. "en_GB.UTF-8" → "en_GB.utf8"
|
||||
let normalized = locale.replace("-", "").to_lowercase();
|
||||
if available.lines().any(|l| l.replace("-", "").to_lowercase() == normalized) {
|
||||
continue;
|
||||
}
|
||||
// Debian's locale-gen ignores positional args — the locale must be
|
||||
// uncommented in /etc/locale.gen or appended to it.
|
||||
tracing::info!("Enabling missing locale for PostgreSQL cluster: {locale}");
|
||||
let locale_gen_path = Path::new("/etc/locale.gen");
|
||||
let contents = tokio::fs::read_to_string(locale_gen_path)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
// Try to uncomment an existing entry first, otherwise append.
|
||||
let entry = format!("{locale} UTF-8");
|
||||
let commented = format!("# {entry}");
|
||||
if contents.contains(&commented) {
|
||||
let updated = contents.replace(&commented, &entry);
|
||||
tokio::fs::write(locale_gen_path, updated).await?;
|
||||
} else if !contents.contains(&entry) {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let mut f = tokio::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(locale_gen_path)
|
||||
.await?;
|
||||
f.write_all(format!("\n{entry}\n").as_bytes()).await?;
|
||||
}
|
||||
need_gen = true;
|
||||
}
|
||||
|
||||
if need_gen {
|
||||
Command::new("locale-gen")
|
||||
.invoke(crate::ErrorKind::Database)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
async fn init_postgres(datadir: impl AsRef<Path>) -> Result<PgPool, Error> {
|
||||
let db_dir = datadir.as_ref().join("main/postgresql");
|
||||
@@ -187,12 +91,6 @@ async fn init_postgres(datadir: impl AsRef<Path>) -> Result<PgPool, Error> {
|
||||
|
||||
crate::disk::mount::util::bind(&db_dir, "/var/lib/postgresql", false).await?;
|
||||
|
||||
// The cluster may have been created with a locale not present on the
|
||||
// current image (e.g. en_GB.UTF-8 on a server that predates the trixie
|
||||
// image). Detect and generate it before starting PostgreSQL, otherwise
|
||||
// PG will start but refuse connections.
|
||||
ensure_cluster_locale(pg_version).await?;
|
||||
|
||||
Command::new("systemctl")
|
||||
.arg("start")
|
||||
.arg(format!("postgresql@{pg_version}-main.service"))
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
/**
|
||||
* Performs a deep structural equality check across all provided arguments.
|
||||
* Returns true only if every argument is deeply equal to every other argument.
|
||||
* Handles primitives, arrays, and plain objects (JSON-like) recursively.
|
||||
*
|
||||
* Non-plain objects (Set, Map, Date, etc.) are compared by reference only,
|
||||
* since Object.keys() does not enumerate their contents.
|
||||
* Handles primitives, arrays, and plain objects recursively.
|
||||
*
|
||||
* @param args - Two or more values to compare for deep equality
|
||||
* @returns True if all arguments are deeply equal
|
||||
@@ -26,18 +23,6 @@ export function deepEqual(...args: unknown[]) {
|
||||
}
|
||||
if (objects.length !== args.length) return false
|
||||
if (objects.some(Array.isArray) && !objects.every(Array.isArray)) return false
|
||||
if (
|
||||
objects.some(
|
||||
(x) => !Array.isArray(x) && Object.getPrototypeOf(x) !== Object.prototype,
|
||||
)
|
||||
) {
|
||||
return (
|
||||
objects.reduce<object | null>(
|
||||
(a, b) => (a === b ? a : null),
|
||||
objects[0],
|
||||
) !== null
|
||||
)
|
||||
}
|
||||
const allKeys = new Set(objects.flatMap((x) => Object.keys(x)))
|
||||
for (const key of allKeys) {
|
||||
for (const x of objects) {
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
VERSION,
|
||||
WorkspaceConfig,
|
||||
} from '@start9labs/shared'
|
||||
import { TUI_WINDOW_SIZE, tuiObfuscateOptionsProvider } from '@taiga-ui/cdk'
|
||||
import { tuiObfuscateOptionsProvider } from '@taiga-ui/cdk'
|
||||
import {
|
||||
provideTaiga,
|
||||
TUI_DIALOGS_CLOSE,
|
||||
@@ -67,12 +67,11 @@ import {
|
||||
PATCH_CACHE,
|
||||
PatchDbSource,
|
||||
} from 'src/app/services/patch-db/patch-db-source'
|
||||
import { PluginsService } from 'src/app/services/plugins.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
import { StorageService } from 'src/app/services/storage.service'
|
||||
import {
|
||||
DatetimeTransformer,
|
||||
DateTransformer,
|
||||
DatetimeTransformer,
|
||||
} from 'src/app/utils/value-transformers'
|
||||
import { environment } from 'src/environments/environment'
|
||||
|
||||
@@ -186,9 +185,5 @@ export const APP_CONFIG: ApplicationConfig = {
|
||||
desktopLarge: Infinity,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TUI_WINDOW_SIZE,
|
||||
useExisting: PluginsService,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import { HeaderStatusComponent } from './status.component'
|
||||
template: `
|
||||
<header-navigation />
|
||||
<div class="item item_center">
|
||||
<ng-content />
|
||||
<div class="mobile"><ng-container #vcr /></div>
|
||||
</div>
|
||||
<header-status class="item item_connection" />
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { WA_IS_MOBILE } from '@ng-web-apis/platform'
|
||||
import { TuiAnimated } from '@taiga-ui/cdk'
|
||||
import { TuiButton, TuiPopup } from '@taiga-ui/core'
|
||||
import { ResizerComponent } from 'src/app/routes/portal/components/resizer.component'
|
||||
import { PluginsService } from 'src/app/services/plugins.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-plugins',
|
||||
template: `
|
||||
<button
|
||||
tuiIconButton
|
||||
iconStart="@tui.bot-message-square"
|
||||
[appearance]="service.enabled() ? 'positive' : 'icon'"
|
||||
(click)="service.enabled.set(!service.enabled())"
|
||||
>
|
||||
AI assistant
|
||||
</button>
|
||||
<aside
|
||||
*tuiPopup="service.enabled()"
|
||||
tuiAnimated
|
||||
[class._mobile]="mobile"
|
||||
[style.--plugins]="service.size() / 100"
|
||||
(click.self)="onClick($any($event).layerX)"
|
||||
>
|
||||
Plugin placeholder
|
||||
</aside>
|
||||
<input
|
||||
*tuiPopup="service.enabled()"
|
||||
appResizer
|
||||
type="range"
|
||||
step="0.1"
|
||||
[(ngModel)]="service.size"
|
||||
/>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
float: inline-end;
|
||||
margin-inline-end: 0.5rem;
|
||||
}
|
||||
|
||||
[tuiIconButton] {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
aside {
|
||||
position: fixed;
|
||||
inset: 0 0 0 calc(320px + (100% - 640px) * var(--plugins));
|
||||
display: flex;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
margin: var(--bumper) var(--bumper) var(--bumper) 0;
|
||||
background: color-mix(in hsl, var(--start9-base-2) 75%, transparent);
|
||||
background-image: linear-gradient(
|
||||
transparent,
|
||||
var(--tui-background-base)
|
||||
);
|
||||
backdrop-filter: blur(1rem);
|
||||
border-radius: var(--bumper);
|
||||
|
||||
--tui-from: translateX(100%);
|
||||
|
||||
&._mobile {
|
||||
inset-inline-start: 20%;
|
||||
box-shadow:
|
||||
inset 0 1px rgba(255, 255, 255, 0.25),
|
||||
0 0 0 100vh rgb(0 0 0 / 50%);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: -100vh;
|
||||
}
|
||||
|
||||
&.tui-enter,
|
||||
&.tui-leave {
|
||||
animation-name: tuiSlide, tuiFade;
|
||||
}
|
||||
}
|
||||
|
||||
&.tui-enter,
|
||||
&.tui-leave {
|
||||
animation-name: tuiSlide;
|
||||
}
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [FormsModule, TuiButton, ResizerComponent, TuiPopup, TuiAnimated],
|
||||
})
|
||||
export class PluginsComponent {
|
||||
protected readonly mobile = inject(WA_IS_MOBILE)
|
||||
protected readonly service = inject(PluginsService)
|
||||
|
||||
protected onClick(layerX: number) {
|
||||
if (layerX < 0 && this.mobile) {
|
||||
this.service.enabled.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core'
|
||||
|
||||
@Component({
|
||||
selector: 'input[type="range"][appResizer]',
|
||||
template: '',
|
||||
styles: `
|
||||
@use '@taiga-ui/styles/utils' as taiga;
|
||||
|
||||
:host {
|
||||
@include taiga.transition(color);
|
||||
|
||||
position: fixed;
|
||||
inset: 0 calc(320px - var(--bumper)) 0 calc(320px - 2 * var(--bumper));
|
||||
appearance: none;
|
||||
pointer-events: none;
|
||||
background: none;
|
||||
color: transparent;
|
||||
outline: none;
|
||||
cursor: ew-resize;
|
||||
|
||||
&:hover {
|
||||
color: var(--tui-background-neutral-1-hover);
|
||||
}
|
||||
|
||||
&::-webkit-slider-runnable-track {
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
&::-webkit-slider-thumb {
|
||||
block-size: 100%;
|
||||
inline-size: calc(var(--bumper) * 3);
|
||||
padding: var(--bumper);
|
||||
appearance: none;
|
||||
background: currentColor;
|
||||
background-clip: content-box;
|
||||
border: none;
|
||||
pointer-events: auto;
|
||||
border-radius: var(--tui-radius-l);
|
||||
}
|
||||
|
||||
&::-moz-range-thumb {
|
||||
block-size: 100%;
|
||||
inline-size: calc(var(--bumper) * 3);
|
||||
padding: var(--bumper);
|
||||
appearance: none;
|
||||
background: currentColor;
|
||||
background-clip: content-box;
|
||||
border: none;
|
||||
pointer-events: auto;
|
||||
border-radius: var(--tui-radius-l);
|
||||
}
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ResizerComponent {}
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
} from '@angular/core'
|
||||
import { toSignal } from '@angular/core/rxjs-interop'
|
||||
import { RouterOutlet } from '@angular/router'
|
||||
import { WA_IS_MOBILE } from '@ng-web-apis/platform'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
import {
|
||||
TuiButton,
|
||||
@@ -22,17 +21,15 @@ import {
|
||||
TuiProgress,
|
||||
} from '@taiga-ui/kit'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { PluginsComponent } from 'src/app/routes/portal/components/plugins.component'
|
||||
import { TabsComponent } from 'src/app/routes/portal/components/tabs.component'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { OSService } from 'src/app/services/os.service'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { PluginsService } from 'src/app/services/plugins.service'
|
||||
import { HeaderComponent } from './components/header/header.component'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<header appHeader><app-plugins /></header>
|
||||
<header appHeader>{{ name() }}</header>
|
||||
<main>
|
||||
<tui-scrollbar [style.max-height.%]="100">
|
||||
<router-outlet />
|
||||
@@ -70,47 +67,19 @@ import { HeaderComponent } from './components/header/header.component'
|
||||
styles: `
|
||||
@use '@taiga-ui/styles/utils' as taiga;
|
||||
|
||||
@keyframes open {
|
||||
from {
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
to {
|
||||
inline-size: calc(320px + (100% - 640px) * var(--plugins));
|
||||
}
|
||||
}
|
||||
|
||||
:host {
|
||||
@include taiga.transition(inline-size);
|
||||
|
||||
block-size: 100%;
|
||||
inline-size: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
// @TODO Theme
|
||||
background: url(/assets/img/background_dark.jpeg) fixed center/cover;
|
||||
|
||||
&._plugins {
|
||||
inline-size: calc(320px + (100% - 640px) * var(--plugins));
|
||||
animation: open var(--tui-duration) ease-in-out;
|
||||
transition: none;
|
||||
|
||||
app-tabs {
|
||||
inline-size: calc(100% - var(--bumper));
|
||||
}
|
||||
}
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
&::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
backdrop-filter: blur(0.5rem);
|
||||
}
|
||||
|
||||
&::after {
|
||||
z-index: -1;
|
||||
// @TODO Theme
|
||||
background: url(/assets/img/background_dark.jpeg) fixed center/cover;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
@@ -132,10 +101,6 @@ import { HeaderComponent } from './components/header/header.component'
|
||||
text-wrap: balance;
|
||||
}
|
||||
`,
|
||||
host: {
|
||||
'[class._plugins]': '!mobile && plugins.enabled()',
|
||||
'[style.--plugins]': 'plugins.size() / 100',
|
||||
},
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
RouterOutlet,
|
||||
@@ -149,7 +114,6 @@ import { HeaderComponent } from './components/header/header.component'
|
||||
TuiButton,
|
||||
TuiPopup,
|
||||
TuiCell,
|
||||
PluginsComponent,
|
||||
],
|
||||
})
|
||||
export class PortalComponent {
|
||||
@@ -158,8 +122,6 @@ export class PortalComponent {
|
||||
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
|
||||
private readonly api = inject(ApiService)
|
||||
|
||||
readonly mobile = inject(WA_IS_MOBILE)
|
||||
readonly plugins = inject(PluginsService)
|
||||
readonly name = toSignal(this.patch.watch$('serverInfo', 'name'))
|
||||
readonly update = toSignal(inject(OSService).updating$)
|
||||
readonly bar = signal(true)
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { inject, Injectable, INJECTOR, Injector, signal } from '@angular/core'
|
||||
import { toObservable } from '@angular/core/rxjs-interop'
|
||||
import { WA_IS_MOBILE } from '@ng-web-apis/platform'
|
||||
import { TUI_WINDOW_SIZE } from '@taiga-ui/cdk'
|
||||
import { TUI_MEDIA } from '@taiga-ui/core'
|
||||
import { combineLatest, map, Observable } from 'rxjs'
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PluginsService extends Observable<DOMRect> {
|
||||
public readonly enabled = signal(false)
|
||||
public readonly size = signal(100)
|
||||
|
||||
// @ts-expect-error triggering TUI_WINDOW_SIZE default factory
|
||||
private readonly window = Injector.create({
|
||||
parent: inject(INJECTOR),
|
||||
providers: [{ provide: TUI_WINDOW_SIZE }],
|
||||
}).get(TUI_WINDOW_SIZE)
|
||||
|
||||
private readonly media = inject(TUI_MEDIA)
|
||||
private readonly stream = inject(WA_IS_MOBILE)
|
||||
? this.window
|
||||
: combineLatest([
|
||||
this.window,
|
||||
toObservable(this.size),
|
||||
toObservable(this.enabled),
|
||||
]).pipe(
|
||||
map(([window, size, enabled]) =>
|
||||
window.width < this.media.mobile || !enabled
|
||||
? window
|
||||
: { ...window, width: 320 + (window.width - 640) * (size / 100) },
|
||||
),
|
||||
)
|
||||
|
||||
constructor() {
|
||||
super(subscriber => this.stream.subscribe(subscriber))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user