onTrigger('left')}
13 | onMouseOut={() => onTrigger(null)}
14 | />
15 | )}
16 |
17 | {hiddenEdge !== 'right' && (
18 |
onTrigger('right')}
21 | onMouseOut={() => onTrigger(null)}
22 | />
23 | )}
24 | >
25 | ),
26 | );
27 |
--------------------------------------------------------------------------------
/src/features/ribbon/ui/ribbon-scroll-trigger/ribbon-scroll-trigger.interface.ts:
--------------------------------------------------------------------------------
1 | // TODO
2 | type TriggerZone = any;
3 |
4 | export type RibbonScrollTriggerProps = {
5 | hiddenEdge: TriggerZone;
6 | onTrigger(zone: TriggerZone): void;
7 | };
8 |
--------------------------------------------------------------------------------
/src/features/ribbon/ui/ribbon-scroll-trigger/ribbon-scroll-trigger.module.scss:
--------------------------------------------------------------------------------
1 | .left,
2 | .right {
3 | position: fixed;
4 | bottom: 0;
5 |
6 | height: 280px;
7 | width: 40px;
8 | }
9 |
10 | .right {
11 | right: 0;
12 | }
13 |
--------------------------------------------------------------------------------
/src/features/ribbon/ui/ribbon/index.ts:
--------------------------------------------------------------------------------
1 | export { Ribbon } from './ribbon.component';
2 |
--------------------------------------------------------------------------------
/src/features/ribbon/ui/ribbon/ribbon.component.tsx:
--------------------------------------------------------------------------------
1 | import { observer } from 'mobx-react-lite';
2 |
3 | import { AnimatePresence, motion } from 'framer-motion';
4 | import type { MotionProps } from 'framer-motion';
5 |
6 | import { useRibbonService } from 'features/ribbon/services';
7 |
8 | import { RibbonAppDrawer } from '../ribbon-app-drawer';
9 | import { RibbonCard } from '../ribbon-card';
10 |
11 | import s from './ribbon.module.scss';
12 |
13 | const motionProps: MotionProps = {
14 | variants: {
15 | hide: {
16 | y: '105%',
17 | transition: {
18 | duration: 0.5,
19 | },
20 | },
21 | show: {
22 | y: 1,
23 | transition: {
24 | ease: 'circOut',
25 | },
26 | },
27 | },
28 | initial: 'hide',
29 | };
30 |
31 | export const Ribbon = observer(() => {
32 | const svc = useRibbonService();
33 |
34 | return (
35 | <>
36 |
42 | {svc.launcherService.visible.map((lp, index) => (
43 |
44 | ))}
45 |
46 |
47 |
{svc.appDrawerService.visible && }
48 | >
49 | );
50 | });
51 |
--------------------------------------------------------------------------------
/src/features/ribbon/ui/ribbon/ribbon.module.scss:
--------------------------------------------------------------------------------
1 | .container {
2 | width: 100vw;
3 | height: 100vh;
4 | }
5 |
6 | .group {
7 | position: fixed;
8 | inset: 0;
9 |
10 | display: flex;
11 | align-items: flex-end;
12 |
13 | overflow-x: scroll;
14 |
15 | &::-webkit-scrollbar {
16 | display: none;
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 |
4 | import { App } from './app';
5 |
6 | import './app/styles/global.scss';
7 |
8 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
9 |
10 |
11 | ,
12 | );
13 |
--------------------------------------------------------------------------------
/src/shared/api/global.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.module.scss' {
2 | const content: Record
;
3 | export default content;
4 | }
5 |
6 | // eslint-disable-next-line @typescript-eslint/naming-convention
7 | declare const __DEV__: boolean;
8 |
9 | declare const process: {
10 | env: {
11 | APP_ID: string;
12 | };
13 | };
14 |
--------------------------------------------------------------------------------
/src/shared/api/webos.d.ts:
--------------------------------------------------------------------------------
1 | export enum Intent {
2 | AddApps = 'add_apps',
3 | }
4 |
5 | interface ActivateType {
6 | activateType?: 'home' | string;
7 |
8 | intent?: Intent;
9 | }
10 |
11 | interface InputRegion {
12 | x: number;
13 | y: number;
14 | width: number;
15 | height: number;
16 | }
17 |
18 | declare global {
19 | class PalmServiceBridge {
20 | constructor(serviceId?: string);
21 |
22 | onservicecallback(serializedMessage: string): void;
23 |
24 | call(uri: string, serializedParameters: string): void;
25 | }
26 |
27 | namespace webOSSystem {
28 | const identifier: string;
29 |
30 | const launchParams: ActivateType;
31 | const launchReason: string;
32 |
33 | /**
34 | * Serialized JSON with basic device info.
35 | */
36 | const deviceInfo: string;
37 |
38 | /**
39 | * Tells compositor to hide the current layer. Works only on webOS 7+.
40 | */
41 | function hide(): void;
42 |
43 | /**
44 | * Tells compositor to activate the UI layer.
45 | */
46 | function activate(): void;
47 |
48 | const window: {
49 | /**
50 | * Set keyboard focus
51 | */
52 | setFocus(focus: boolean): void;
53 |
54 | /**
55 | * Set floating window input region
56 | */
57 | setInputRegion(regions: [region: InputRegion]): void;
58 | };
59 | }
60 |
61 | interface Document {
62 | addEventListener(
63 | type: 'webOSRelaunch',
64 | listener: (this: Document, event: CustomEvent) => void,
65 | ): void;
66 |
67 | removeEventListener(
68 | type: 'webOSRelaunch',
69 | listener: (this: Document, event: CustomEvent) => void,
70 | ): void;
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/shared/core/di/container.context.ts:
--------------------------------------------------------------------------------
1 | import { createContext, useContext } from 'react';
2 |
3 | import type { Container } from 'inversify';
4 |
5 | type ContainerContextValue = Container | null;
6 |
7 | export const ContainerContext = createContext(null);
8 |
9 | export const useContainer = () => useContext(ContainerContext)!;
10 |
--------------------------------------------------------------------------------
/src/shared/core/di/container.provider.tsx:
--------------------------------------------------------------------------------
1 | import type React from 'react';
2 |
3 | import type { Container } from 'inversify';
4 |
5 | import { ContainerContext } from './container.context';
6 |
7 | type ContainerProviderProps = {
8 | container: Container;
9 | children: React.ReactNode;
10 | };
11 |
12 | export const ContainerProvider = ({ container, children }: ContainerProviderProps) => (
13 | {children}
14 | );
15 |
--------------------------------------------------------------------------------
/src/shared/core/di/di.container.ts:
--------------------------------------------------------------------------------
1 | import { Container } from 'inversify';
2 |
3 | export const container = new Container({
4 | autoBindInjectable: true,
5 | defaultScope: 'Singleton',
6 | });
7 |
--------------------------------------------------------------------------------
/src/shared/core/di/index.ts:
--------------------------------------------------------------------------------
1 | export { container } from './di.container';
2 |
3 | export { ContainerProvider } from './container.provider';
4 | export { ContainerContext, useContainer } from './container.context';
5 |
--------------------------------------------------------------------------------
/src/shared/core/utils/throttle.ts:
--------------------------------------------------------------------------------
1 | export const throttle = (
2 | fn: (...args: A) => void,
3 | wait: number,
4 | ): ((...args: A) => void) => {
5 | let timerId: ReturnType;
6 | let lastTick: number = 0;
7 |
8 | return (...args: A) => {
9 | const delta = Date.now() - lastTick;
10 |
11 | clearTimeout(timerId);
12 |
13 | if (delta > wait) {
14 | lastTick = Date.now();
15 |
16 | fn.apply(this, args);
17 | } else {
18 | timerId = setTimeout(() => fn.apply(this, args), wait);
19 | }
20 | };
21 | };
22 |
--------------------------------------------------------------------------------
/src/shared/services/launcher/api/launch-point.interface.ts:
--------------------------------------------------------------------------------
1 | import type { LaunchPoint } from '../model/launch-point.model';
2 |
3 | export type LaunchPointInput = {
4 | id: string;
5 | title: string;
6 |
7 | launchPointId: string;
8 |
9 | removable: boolean;
10 | iconColor: string;
11 |
12 | icon: string;
13 | mediumLargeIcon?: string;
14 | largeIcon?: string;
15 | extraLargeIcon?: string;
16 |
17 | builtin?: boolean;
18 | params?: Record;
19 | };
20 |
21 | export type LaunchPointInstance = LaunchPoint;
22 |
23 | export type LaunchPointFactory = (snapshot: LaunchPointInput) => LaunchPointInstance;
24 |
--------------------------------------------------------------------------------
/src/shared/services/launcher/index.ts:
--------------------------------------------------------------------------------
1 | export { launcherModule } from './launcher.module';
2 |
3 | export { LauncherService } from './model/launcher.service';
4 |
5 | export { LaunchPoint } from './model/launch-point.model';
6 |
7 | export type {
8 | LaunchPointInput,
9 | LaunchPointInstance,
10 | LaunchPointFactory,
11 | } from './api/launch-point.interface';
12 |
--------------------------------------------------------------------------------
/src/shared/services/launcher/launcher.module.ts:
--------------------------------------------------------------------------------
1 | import { ContainerModule } from 'inversify';
2 |
3 | import type { LaunchPointInput, LaunchPointInstance } from './api/launch-point.interface';
4 | import { launchPointFactorySymbol } from './launcher.tokens';
5 | import { LaunchPoint } from './model/launch-point.model';
6 | import { LauncherService } from './model/launcher.service';
7 | import {
8 | AppManagerProvider,
9 | InputProvider,
10 | InternalProvider,
11 | LaunchPointsProvider,
12 | } from './providers';
13 |
14 | export const launcherModule = new ContainerModule(bind => {
15 | bind(LauncherService).toSelf();
16 | bind(LaunchPoint).toSelf().inTransientScope();
17 |
18 | bind(launchPointFactorySymbol).toFactory(
19 | context => snapshot => context.container.get(LaunchPoint).apply(snapshot),
20 | );
21 |
22 | bind(LaunchPointsProvider).to(InputProvider);
23 | bind(LaunchPointsProvider).to(AppManagerProvider);
24 | bind(LaunchPointsProvider).to(InternalProvider);
25 | });
26 |
--------------------------------------------------------------------------------
/src/shared/services/launcher/launcher.tokens.ts:
--------------------------------------------------------------------------------
1 | export const launchPointFactorySymbol = Symbol('Factory');
2 |
--------------------------------------------------------------------------------
/src/shared/services/launcher/model/launch-point.model.ts:
--------------------------------------------------------------------------------
1 | import { inject, injectable } from 'inversify';
2 |
3 | import type { LaunchPointInput } from '../api/launch-point.interface';
4 |
5 | import { LauncherService } from './launcher.service';
6 |
7 | type NonFunctionPropertyNames = {
8 | [K in keyof T]: T[K] extends Function ? never : K;
9 | }[keyof T];
10 |
11 | type NonFunctionProperties = Pick>;
12 |
13 | @injectable()
14 | export class LaunchPoint {
15 | appId!: string;
16 | title!: string;
17 | launchPointId!: string;
18 |
19 | builtin!: boolean;
20 | removable!: boolean;
21 |
22 | icon!: string;
23 | iconColor!: string;
24 |
25 | params: Record = {};
26 |
27 | public constructor(
28 | @inject(LauncherService) private readonly launcherService: LauncherService,
29 | ) {}
30 |
31 | public launch(): Promise {
32 | return this.launcherService.launch(this);
33 | }
34 |
35 | public move(shift: number) {
36 | this.launcherService.move(this, shift);
37 | }
38 |
39 | public show() {
40 | this.launcherService.show(this);
41 | }
42 |
43 | public hide() {
44 | this.launcherService.hide(this);
45 | }
46 |
47 | public uninstall() {
48 | return this.launcherService.uninstall(this);
49 | }
50 |
51 | public apply(snapshot: LaunchPointInput): LaunchPoint {
52 | const {
53 | title,
54 | launchPointId,
55 | removable,
56 | iconColor,
57 | builtin = false,
58 | params = {},
59 | } = snapshot;
60 |
61 | return Object.assign(this, {
62 | appId: snapshot.id,
63 | icon: LaunchPoint.normalizeIcon(snapshot),
64 | title,
65 | launchPointId,
66 | removable,
67 | iconColor,
68 | builtin,
69 | params,
70 | } satisfies NonFunctionProperties);
71 | }
72 |
73 | private static normalizePath(path: string) {
74 | return path.startsWith('/') ? `./root${path}` : path;
75 | }
76 |
77 | private static normalizeIcon(snapshot: LaunchPointInput) {
78 | return LaunchPoint.normalizePath(
79 | snapshot.mediumLargeIcon ||
80 | snapshot.largeIcon ||
81 | snapshot.extraLargeIcon ||
82 | snapshot.icon,
83 | );
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/shared/services/launcher/model/launcher.service.ts:
--------------------------------------------------------------------------------
1 | import { keys, makeAutoObservable, observable, reaction } from 'mobx';
2 | import type { ObservableMap } from 'mobx';
3 |
4 | import { inject, injectable, multiInject } from 'inversify';
5 |
6 | import { luna } from 'shared/services/luna';
7 | import { SettingsService } from 'shared/services/settings';
8 |
9 | import { LifecycleManagerService } from '../../lifecycle-manager';
10 | import type {
11 | LaunchPointFactory,
12 | LaunchPointInput,
13 | LaunchPointInstance,
14 | } from '../api/launch-point.interface';
15 | import { launchPointFactorySymbol } from '../launcher.tokens';
16 | import { LaunchPointsProvider } from '../providers';
17 |
18 | @injectable()
19 | export class LauncherService {
20 | private readonly launchPointsMap = observable.map();
21 |
22 | public constructor(
23 | @inject(SettingsService) private readonly settingsService: SettingsService,
24 | @inject(LifecycleManagerService) private readonly lifecycleManager: LifecycleManagerService,
25 | @inject(launchPointFactorySymbol) private readonly launchPointFactory: LaunchPointFactory,
26 | @multiInject(LaunchPointsProvider) private readonly providers: LaunchPointsProvider[],
27 | ) {
28 | makeAutoObservable(
29 | this,
30 | { pickByIds: false },
31 | { autoBind: true },
32 | );
33 |
34 | reaction(
35 | () => this.launchPoints,
36 | lps => this.launchPointsMap.replace(lps.map(lp => [lp.launchPointId, lp])),
37 | );
38 | }
39 |
40 | public get fulfilled() {
41 | return this.providers.every(x => x.fulfilled);
42 | }
43 |
44 | public get launchPoints(): LaunchPointInstance[] {
45 | if (!this.fulfilled) {
46 | return [];
47 | }
48 |
49 | return this.providers.flatMap(x => x.launchPoints).map(this.resolveLaunchPoint);
50 | }
51 |
52 | public get visible() {
53 | // TODO
54 | return this.pickByIds([...this.order, '@intent:add_apps']);
55 | }
56 |
57 | public get hidden() {
58 | return this.pickByIds(this.hiddenIds);
59 | }
60 |
61 | public async launch({ appId, builtin, params }: LaunchPointInstance) {
62 | if (!builtin) {
63 | this.lifecycleManager.broadcastHide();
64 | }
65 |
66 | return luna('luna://com.webos.service.applicationManager/launch', { id: appId, params });
67 | }
68 |
69 | public show({ launchPointId }: LaunchPointInstance) {
70 | if (!this.order.includes(launchPointId)) {
71 | this.order = [...this.order, launchPointId];
72 | }
73 | }
74 |
75 | public hide({ launchPointId }: LaunchPointInstance) {
76 | this.order = this.order.filter(x => x !== launchPointId);
77 | }
78 |
79 | public async uninstall(lp: LaunchPointInstance) {
80 | this.hide(lp);
81 |
82 | return luna('luna://com.webos.appInstallService/remove', { id: lp.appId });
83 | }
84 |
85 | public move(lp: LaunchPointInstance, shift: number) {
86 | const from = this.visible.indexOf(lp);
87 | const to = from + shift;
88 |
89 | if (to < 0 || to > this.visible.length - 1) {
90 | return;
91 | }
92 |
93 | if (from !== to) {
94 | const ids = this.visible.map(x => x.launchPointId);
95 |
96 | ids.splice(from, 1);
97 | ids.splice(to, 0, lp.launchPointId);
98 |
99 | this.order = ids;
100 | }
101 | }
102 |
103 | private get order() {
104 | return this.settingsService.order.filter(x => !x.startsWith('@'));
105 | }
106 |
107 | private set order(value: string[]) {
108 | this.settingsService.order = value;
109 | }
110 |
111 | private get hiddenIds() {
112 | return keys(this.launchPointsMap as ObservableMap).filter(
113 | id => !this.order.includes(id) && !id.startsWith('@'),
114 | );
115 | }
116 |
117 | private resolveLaunchPoint(snapshot: LaunchPointInput) {
118 | return (
119 | this.launchPointsMap.get(snapshot.launchPointId)?.apply(snapshot) ??
120 | this.launchPointFactory(snapshot)
121 | );
122 | }
123 |
124 | private pickByIds(ids: string[]): LaunchPointInstance[] {
125 | return ids
126 | .map(id => this.launchPointsMap.get(id))
127 | .filter((lp): lp is LaunchPointInstance => Boolean(lp));
128 | }
129 | }
130 |
--------------------------------------------------------------------------------
/src/shared/services/launcher/providers/app-manager/app-manager.interface.ts:
--------------------------------------------------------------------------------
1 | import type { LunaMessage } from 'shared/services/luna';
2 |
3 | import type { LaunchPointInput } from '../../api/launch-point.interface';
4 |
5 | type LaunchPointChangeMixin = {
6 | change: 'added' | 'removed' | 'updated';
7 | };
8 |
9 | type LaunchPointsListMessage = {
10 | launchPoints: LaunchPointInput[];
11 | };
12 |
13 | type LaunchPointMutationMessage = LaunchPointInput & LaunchPointChangeMixin;
14 |
15 | export type AppManagerMessage = LunaMessage;
16 |
--------------------------------------------------------------------------------
/src/shared/services/launcher/providers/app-manager/app-manager.provider.ts:
--------------------------------------------------------------------------------
1 | import { comparer, makeAutoObservable, observable, reaction } from 'mobx';
2 |
3 | import { injectable } from 'inversify';
4 |
5 | import { LunaTopic } from 'shared/services/luna';
6 |
7 | import type { LaunchPointInput } from '../../api/launch-point.interface';
8 | import type { LaunchPointsProvider } from '../launch-points.provider';
9 |
10 | import type { AppManagerMessage } from './app-manager.interface';
11 |
12 | @injectable()
13 | export class AppManagerProvider implements LaunchPointsProvider {
14 | public launchPoints: LaunchPointInput[] = observable.array([], { equals: comparer.structural });
15 |
16 | private topic = new LunaTopic(
17 | 'luna://com.webos.service.applicationManager/listLaunchPoints',
18 | );
19 |
20 | public constructor() {
21 | makeAutoObservable(this, {}, { autoBind: true });
22 |
23 | reaction(() => this.topic.message!, this.handleMessage);
24 | }
25 |
26 | public get fulfilled(): boolean {
27 | return Boolean(this.topic.message);
28 | }
29 |
30 | private handleMessage(message: AppManagerMessage): void {
31 | if (!message.returnValue) {
32 | return;
33 | }
34 |
35 | if ('launchPoints' in message) {
36 | this.launchPoints = message.launchPoints;
37 |
38 | return;
39 | }
40 |
41 | if (!('change' in message)) {
42 | return;
43 | }
44 |
45 | const { change } = message;
46 |
47 | if (change === 'added') {
48 | this.launchPoints.push(message);
49 | }
50 |
51 | const ref = this.launchPoints.find(x => x.id === message.id);
52 |
53 | if (!ref) {
54 | console.warn(`Unable to find referenced launch point: ${message.id}`);
55 | return;
56 | }
57 |
58 | if (change === 'updated') {
59 | Object.assign(ref, message);
60 | }
61 |
62 | if (change === 'removed') {
63 | this.launchPoints = this.launchPoints.filter(x => ref !== x);
64 | }
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/shared/services/launcher/providers/index.ts:
--------------------------------------------------------------------------------
1 | export { AppManagerProvider } from './app-manager/app-manager.provider';
2 | export { InputProvider } from './input-manager/input-manager.provider';
3 | export { InternalProvider } from './internal/internal.provider';
4 |
5 | export { LaunchPointsProvider } from './launch-points.provider';
6 |
--------------------------------------------------------------------------------
/src/shared/services/launcher/providers/input-manager/input-manager.interface.ts:
--------------------------------------------------------------------------------
1 | import type { LunaMessage } from 'shared/services/luna';
2 |
3 | export type Device = {
4 | label: string;
5 | appId: string;
6 |
7 | connected: boolean;
8 | activate: boolean;
9 |
10 | iconPrefix: string;
11 | icon: string;
12 | };
13 |
14 | type InputStatusMessage = {
15 | devices: Device[];
16 | };
17 |
18 | export type InputManagerMessage = LunaMessage;
19 |
--------------------------------------------------------------------------------
/src/shared/services/launcher/providers/input-manager/input-manager.provider.ts:
--------------------------------------------------------------------------------
1 | import { computed, makeObservable } from 'mobx';
2 |
3 | import { injectable } from 'inversify';
4 |
5 | import { LunaTopic } from 'shared/services/luna';
6 |
7 | import type { LaunchPointInput } from '../../api/launch-point.interface';
8 | import type { LaunchPointsProvider } from '../launch-points.provider';
9 |
10 | import type { Device, InputManagerMessage } from './input-manager.interface';
11 |
12 | @injectable()
13 | export class InputProvider implements LaunchPointsProvider {
14 | private topic = new LunaTopic(
15 | 'luna://com.webos.service.eim/getAllInputStatus',
16 | );
17 |
18 | public constructor() {
19 | makeObservable(
20 | this,
21 | { fulfilled: computed, launchPoints: computed.struct },
22 | { autoBind: true },
23 | );
24 | }
25 |
26 | public get fulfilled(): boolean {
27 | return Boolean(this.topic.message);
28 | }
29 |
30 | public get launchPoints(): LaunchPointInput[] {
31 | const { message } = this.topic;
32 |
33 | if (!message?.returnValue) {
34 | return [];
35 | }
36 |
37 | return message.devices.map(this.mapDeviceToLaunchPoint);
38 | }
39 |
40 | private mapDeviceToLaunchPoint(device: Device): LaunchPointInput {
41 | return {
42 | id: device.appId,
43 | launchPointId: device.appId,
44 | title: device.label,
45 | icon: `./root${device.iconPrefix}${device.icon}`,
46 | iconColor: '#ffffff',
47 | removable: false,
48 | };
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/shared/services/launcher/providers/internal/internal.provider.ts:
--------------------------------------------------------------------------------
1 | import { injectable } from 'inversify';
2 |
3 | import { Intent } from 'shared/api/webos.d';
4 | import type { ActivateType } from 'shared/api/webos.d';
5 |
6 | import type { LaunchPointInput } from '../../api/launch-point.interface';
7 | import type { LaunchPointsProvider } from '../launch-points.provider';
8 |
9 | import plus from 'assets/plus.png';
10 |
11 | @injectable()
12 | export class InternalProvider implements LaunchPointsProvider {
13 | public fulfilled = true;
14 |
15 | public launchPoints = [
16 | {
17 | id: 'com.kitsuned.althome',
18 | launchPointId: '@intent:add_apps',
19 | title: 'Add apps',
20 | builtin: true,
21 | removable: false,
22 | iconColor: '#242424',
23 | icon: plus,
24 | params: {
25 | intent: Intent.AddApps,
26 | },
27 | },
28 | ];
29 | }
30 |
--------------------------------------------------------------------------------
/src/shared/services/launcher/providers/launch-points.provider.ts:
--------------------------------------------------------------------------------
1 | import { injectable } from 'inversify';
2 |
3 | import type { LaunchPointInput } from '../api/launch-point.interface';
4 |
5 | @injectable()
6 | export abstract class LaunchPointsProvider {
7 | public abstract get fulfilled(): boolean;
8 |
9 | public abstract get launchPoints(): LaunchPointInput[];
10 | }
11 |
--------------------------------------------------------------------------------
/src/shared/services/lifecycle-manager/api/compositor.interface.ts:
--------------------------------------------------------------------------------
1 | export type LifecycleEventType =
2 | | 'splash'
3 | | 'launch'
4 | | 'foreground'
5 | | 'background'
6 | | 'stop'
7 | | 'close';
8 |
9 | export type LifecycleEvent = {
10 | event: LifecycleEventType;
11 | appId: string;
12 | };
13 |
--------------------------------------------------------------------------------
/src/shared/services/lifecycle-manager/api/lifecycle-manager.interface.ts:
--------------------------------------------------------------------------------
1 | import type { Intent } from 'shared/api/webos';
2 |
3 | export type LifecycleManagerEvents = {
4 | relaunch: void;
5 | requestHide: void;
6 | intent: Intent;
7 | };
8 |
--------------------------------------------------------------------------------
/src/shared/services/lifecycle-manager/index.ts:
--------------------------------------------------------------------------------
1 | export { lifecycleManagerModule } from './lifecycle-manager.module';
2 |
3 | export { LifecycleManagerService } from './service/lifecycle-manager.service';
4 |
--------------------------------------------------------------------------------
/src/shared/services/lifecycle-manager/lifecycle-manager.module.ts:
--------------------------------------------------------------------------------
1 | import { ContainerModule } from 'inversify';
2 |
3 | import { LifecycleManagerService } from './service/lifecycle-manager.service';
4 |
5 | export const lifecycleManagerModule = new ContainerModule(bind => {
6 | bind(LifecycleManagerService).toSelf();
7 | });
8 |
--------------------------------------------------------------------------------
/src/shared/services/lifecycle-manager/service/lifecycle-manager.service.ts:
--------------------------------------------------------------------------------
1 | import { makeAutoObservable, reaction } from 'mobx';
2 |
3 | import { inject, injectable } from 'inversify';
4 | import mitt from 'mitt';
5 |
6 | import type { ActivateType } from 'shared/api/webos.d';
7 | import { luna, LunaTopic } from 'shared/services/luna';
8 | import { SystemInfoService } from 'shared/services/system-info';
9 |
10 | import type { LifecycleEvent } from '../api/compositor.interface';
11 | import type { LifecycleManagerEvents } from '../api/lifecycle-manager.interface';
12 |
13 | @injectable()
14 | export class LifecycleManagerService {
15 | public emitter = mitt();
16 |
17 | private topic = new LunaTopic(
18 | 'luna://com.webos.service.applicationManager/getAppLifeEvents',
19 | );
20 |
21 | private visible: boolean = true;
22 |
23 | public constructor(
24 | @inject(SystemInfoService) private readonly systemInfoService: SystemInfoService,
25 | ) {
26 | makeAutoObservable(this, {}, { autoBind: true });
27 |
28 | reaction(
29 | () => this.topic.message,
30 | message => {
31 | if (
32 | message?.appId !== process.env.APP_ID &&
33 | (message?.event === 'splash' || message?.event === 'launch')
34 | ) {
35 | this.broadcastHide();
36 | }
37 | },
38 | );
39 |
40 | document.addEventListener('webOSRelaunch', this.handleRelaunch);
41 | }
42 |
43 | public show() {
44 | this.visible = true;
45 |
46 | webOSSystem.activate();
47 | }
48 |
49 | public hide() {
50 | this.visible = false;
51 |
52 | if (this.compositorShimsRequired) {
53 | this.requestSuspense();
54 | } else {
55 | webOSSystem.hide();
56 | }
57 | }
58 |
59 | public broadcastHide() {
60 | if (this.visible) {
61 | if (__DEV__) {
62 | console.log('broadcasting hide request');
63 | }
64 |
65 | this.emitter.emit('requestHide');
66 | }
67 | }
68 |
69 | private get compositorShimsRequired() {
70 | if (this.systemInfoService.osMajorVersion === 7) {
71 | return this.systemInfoService.osMinorVersion! < 3;
72 | }
73 |
74 | return this.systemInfoService.osMajorVersion
75 | ? this.systemInfoService?.osMajorVersion < 7
76 | : true;
77 | }
78 |
79 | private handleRelaunch(event: CustomEvent) {
80 | if (event.detail?.intent) {
81 | if (__DEV__) {
82 | console.log('broadcasting intent', event.detail);
83 | }
84 |
85 | this.emitter.emit('intent', event.detail.intent);
86 | } else if (event.detail?.activateType === 'home' && !this.visible) {
87 | this.emitter.emit('relaunch');
88 | } else if (this.visible) {
89 | this.emitter.emit('requestHide');
90 | }
91 | }
92 |
93 | private requestSuspense() {
94 | void luna('luna://com.webos.service.applicationManager/suspense', {
95 | id: process.env.APP_ID,
96 | });
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/src/shared/services/luna/api/luna.api.ts:
--------------------------------------------------------------------------------
1 | export type LunaErrorMessage = {
2 | returnValue: false;
3 | errorCode?: number;
4 | errorText?: string;
5 | };
6 |
7 | type DeepNever = {
8 | [K in keyof T]: T[K] extends Record ? DeepNever : never;
9 | };
10 |
11 | export type LunaMessage = {}> =
12 | | (LunaErrorMessage & DeepNever)
13 | | (T & { returnValue: true });
14 |
15 | export type LunaRequestParams = Record> = T & {
16 | subscribe?: boolean;
17 | };
18 |
--------------------------------------------------------------------------------
/src/shared/services/luna/index.ts:
--------------------------------------------------------------------------------
1 | export { luna, LunaTopic } from './model/luna.service';
2 |
3 | export * from './api/luna.api';
4 |
--------------------------------------------------------------------------------
/src/shared/services/luna/lib/auto-elevator.lib.ts:
--------------------------------------------------------------------------------
1 | import type { LunaMessage } from '../api/luna.api';
2 | import { luna } from '../model/luna.service';
3 |
4 | export const requestElevation = async () => {
5 | const { root } = await luna<{ root: boolean }>(
6 | 'luna://org.webosbrew.hbchannel.service/getConfiguration',
7 | );
8 |
9 | if (!root) {
10 | await luna('luna://com.webos.notification/createToast', {
11 | message: '[AltHome] Check root status!',
12 | });
13 |
14 | return;
15 | }
16 |
17 | await luna('luna://com.webos.notification/createToast', {
18 | message: '[AltHome] Getting things ready…',
19 | });
20 |
21 | await luna('luna://org.webosbrew.hbchannel.service/exec', {
22 | command:
23 | '/media/developer/apps/usr/palm/applications/com.kitsuned.althome/service --self-elevation',
24 | });
25 |
26 | window.close();
27 | };
28 |
29 | export const verifyMessageContents = (message: LunaMessage) => {
30 | if (!message.returnValue && message.errorText?.startsWith('Denied method call')) {
31 | void requestElevation();
32 | }
33 | };
34 |
--------------------------------------------------------------------------------
/src/shared/services/luna/model/luna.service.ts:
--------------------------------------------------------------------------------
1 | // TODO
2 | // eslint-disable-next-line max-classes-per-file
3 | import { makeAutoObservable, reaction, toJS } from 'mobx';
4 |
5 | import type { LunaMessage, LunaRequestParams } from '../api/luna.api';
6 | import { verifyMessageContents } from '../lib/auto-elevator.lib';
7 |
8 | export class LunaTopic, P extends LunaRequestParams = {}> {
9 | public message: LunaMessage | null = null;
10 |
11 | private bridge!: PalmServiceBridge;
12 |
13 | public constructor(private readonly uri: string, private readonly params?: P) {
14 | makeAutoObservable, 'bridge'>(this, { bridge: false }, { autoBind: true });
15 |
16 | this.subscribe();
17 |
18 | if (__DEV__) {
19 | console.log('', uri);
20 |
21 | reaction(
22 | () => this.message,
23 | message => console.log('<*-', uri, toJS(message)),
24 | );
25 | }
26 | }
27 |
28 | private subscribe() {
29 | this.bridge = new PalmServiceBridge();
30 |
31 | this.bridge.onservicecallback = this.handleCallback;
32 |
33 | this.bridge.call(this.uri, JSON.stringify(this.params ?? { subscribe: true }));
34 | }
35 |
36 | private handleCallback(serialized: string) {
37 | this.message = JSON.parse(serialized);
38 |
39 | if (this.message) {
40 | verifyMessageContents(this.message);
41 | }
42 | }
43 | }
44 |
45 | class LunaOneShot, P extends LunaRequestParams = {}> {
46 | private readonly bridge: PalmServiceBridge = new PalmServiceBridge();
47 |
48 | public constructor(public readonly uri: string, public readonly params?: P) {}
49 |
50 | public call() {
51 | return new Promise((resolve, reject) => {
52 | this.bridge.onservicecallback = (message: string) => {
53 | const parsed = JSON.parse(message);
54 |
55 | if (__DEV__) {
56 | console.log('<--', this.uri, parsed);
57 | }
58 |
59 | if (parsed.errorCode || !parsed.returnValue) {
60 | verifyMessageContents(parsed);
61 |
62 | reject(parsed);
63 | }
64 |
65 | resolve(parsed);
66 | };
67 |
68 | if (__DEV__) {
69 | console.log('-->', this.uri, this.params);
70 | }
71 |
72 | this.bridge.call(this.uri, JSON.stringify(this.params ?? {}));
73 | });
74 | }
75 | }
76 |
77 | export const luna = , P extends LunaRequestParams = {}>(
78 | uri: string,
79 | params?: P,
80 | ) => new LunaOneShot(uri, params).call();
81 |
--------------------------------------------------------------------------------
/src/shared/services/services.init.ts:
--------------------------------------------------------------------------------
1 | import { container } from '@di';
2 |
3 | import { launcherModule } from './launcher';
4 | import { lifecycleManagerModule } from './lifecycle-manager';
5 | import { settingsModule } from './settings';
6 | import { systemInfoModule } from './system-info';
7 |
8 | container.load(systemInfoModule, settingsModule, lifecycleManagerModule, launcherModule);
9 |
--------------------------------------------------------------------------------
/src/shared/services/settings/index.ts:
--------------------------------------------------------------------------------
1 | export { settingsModule } from './settings.module';
2 |
3 | export { SettingsService } from './model/settings.service';
4 |
--------------------------------------------------------------------------------
/src/shared/services/settings/model/settings.service.ts:
--------------------------------------------------------------------------------
1 | import { comparer, makeAutoObservable, reaction, toJS, when } from 'mobx';
2 |
3 | import { injectable } from 'inversify';
4 |
5 | import { throttle } from 'shared/core/utils/throttle';
6 | import { luna, LunaTopic } from 'shared/services/luna';
7 |
8 | const KEY = process.env.APP_ID as 'com.kitsuned.althome';
9 |
10 | type ConfigMessage = {
11 | configs: {
12 | [KEY]: Settings;
13 | };
14 | missingConfigs: string[];
15 | };
16 |
17 | type Settings = Omit;
18 |
19 | @injectable()
20 | export class SettingsService {
21 | public hydrated: boolean = false;
22 |
23 | public memoryQuirks: boolean = true;
24 | public wheelVelocityFactor: number = 1.5;
25 | public addNewApps: boolean = true;
26 | public order: string[] = [];
27 |
28 | private topic = new LunaTopic('luna://com.webos.service.config/getConfigs', {
29 | configNames: [KEY],
30 | subscribe: true,
31 | });
32 |
33 | public constructor() {
34 | this.saveConfig = throttle(this.saveConfig, 5 * 1000);
35 |
36 | makeAutoObservable(this, {}, { autoBind: true });
37 |
38 | reaction(
39 | () => this.topic.message?.configs?.[KEY],
40 | settings => this.hydrate(settings ?? {}),
41 | );
42 |
43 | when(
44 | () => Boolean(this.topic.message?.missingConfigs),
45 | () => this.hydrate({}),
46 | );
47 |
48 | when(
49 | () => this.hydrated,
50 | () => reaction(() => this.serialized, this.saveConfig, { equals: comparer.structural }),
51 | );
52 | }
53 |
54 | private saveConfig(serialized: Settings) {
55 | void luna('luna://com.webos.service.config/setConfigs', {
56 | configs: {
57 | [KEY]: serialized,
58 | },
59 | });
60 | }
61 |
62 | private get serialized(): Settings {
63 | const { topic, hydrated, ...settings } = toJS(this);
64 |
65 | return settings;
66 | }
67 |
68 | private hydrate(json: Partial) {
69 | this.hydrated = true;
70 |
71 | Object.assign(this, json);
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src/shared/services/settings/settings.module.ts:
--------------------------------------------------------------------------------
1 | import { ContainerModule } from 'inversify';
2 |
3 | import { SettingsService } from './model/settings.service';
4 |
5 | export const settingsModule = new ContainerModule(bind => {
6 | bind(SettingsService).toSelf();
7 | });
8 |
--------------------------------------------------------------------------------
/src/shared/services/system-info/api/system-info.interface.ts:
--------------------------------------------------------------------------------
1 | import type { LunaMessage } from '../../luna';
2 | import type { systemInfoKeys } from '../lib/system-info-keys.lib';
3 |
4 | export type SystemInfo = Record<(typeof systemInfoKeys)[number], string>;
5 |
6 | export type SystemInfoMessage = LunaMessage;
7 |
--------------------------------------------------------------------------------
/src/shared/services/system-info/index.ts:
--------------------------------------------------------------------------------
1 | export { systemInfoModule } from './system-info.module';
2 |
3 | export { SystemInfoService } from './model/system-info.service';
4 |
--------------------------------------------------------------------------------
/src/shared/services/system-info/lib/system-info-keys.lib.ts:
--------------------------------------------------------------------------------
1 | export const systemInfoKeys = ['firmwareVersion', 'sdkVersion', 'modelName'] as const;
2 |
--------------------------------------------------------------------------------
/src/shared/services/system-info/model/system-info.service.ts:
--------------------------------------------------------------------------------
1 | import { makeAutoObservable, runInAction } from 'mobx';
2 |
3 | import { injectable } from 'inversify';
4 |
5 | import { luna } from '../../luna';
6 | import type { SystemInfoMessage } from '../api/system-info.interface';
7 | import { systemInfoKeys } from '../lib/system-info-keys.lib';
8 |
9 | @injectable()
10 | export class SystemInfoService {
11 | public firmwareVersion: string | null = null;
12 | public modelName: string | null = null;
13 | public sdkVersion: string | null = null;
14 |
15 | public constructor() {
16 | makeAutoObservable(this, {}, { autoBind: true });
17 |
18 | void luna('luna://com.webos.service.tv.systemproperty/getSystemInfo', {
19 | keys: systemInfoKeys,
20 | }).then(({ returnValue, ...rest }) => {
21 | if (returnValue) {
22 | runInAction(() => Object.assign(this, rest));
23 | }
24 | });
25 | }
26 |
27 | public get osMajorVersion(): number | null {
28 | return this.osVersionParts ? this.osVersionParts[0] : null;
29 | }
30 |
31 | public get osMinorVersion(): number | null {
32 | return this.osVersionParts ? this.osVersionParts[1] : null;
33 | }
34 |
35 | private get osVersionParts(): [number, number, number] | null {
36 | return this.sdkVersion
37 | ? (this.sdkVersion.split('.').map(x => Number(x)) as [number, number, number])
38 | : null;
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/shared/services/system-info/system-info.module.ts:
--------------------------------------------------------------------------------
1 | import { ContainerModule } from 'inversify';
2 |
3 | import { SystemInfoService } from './model/system-info.service';
4 |
5 | export const systemInfoModule = new ContainerModule(bind => {
6 | bind(SystemInfoService).toSelf();
7 | });
8 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": [
5 | "ES2020",
6 | "DOM"
7 | ],
8 | "types": [
9 | "reflect-metadata"
10 | ],
11 | "module": "ES2020",
12 | "moduleResolution": "Node",
13 | "jsx": "react-jsx",
14 | "baseUrl": "./src",
15 | "allowSyntheticDefaultImports": true,
16 | "allowUmdGlobalAccess": true,
17 | "declaration": false,
18 | "esModuleInterop": false,
19 | "isolatedModules": true,
20 | "noEmit": true,
21 | "resolveJsonModule": true,
22 | "skipLibCheck": true,
23 | "strict": true,
24 | "useDefineForClassFields": true,
25 | "experimentalDecorators": true,
26 | "paths": {
27 | "@di": ["shared/core/di"]
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/webpack.app.ts:
--------------------------------------------------------------------------------
1 | import { ProvidePlugin, DefinePlugin } from 'webpack';
2 |
3 | import CopyPlugin from 'copy-webpack-plugin';
4 | import HtmlWebpackPlugin from 'html-webpack-plugin';
5 | import MiniCssExtractPlugin from 'mini-css-extract-plugin';
6 | import TSConfigPathsPlugin from 'tsconfig-paths-webpack-plugin';
7 |
8 | import { JsonTransformer } from 'webpack-utils';
9 | import type { WebpackConfigFunction } from 'webpack-utils';
10 |
11 | import { id, version } from './package.json';
12 |
13 | const transformer = new JsonTransformer({
14 | APP_ID: id,
15 | APP_VERSION: version,
16 | });
17 |
18 | const config: WebpackConfigFunction<{ WEBPACK_SERVE?: boolean }> = (_, argv) => ({
19 | id,
20 | name: 'app',
21 | target: 'web',
22 | mode: argv.mode ?? 'development',
23 | entry: './src/index.tsx',
24 | devtool: 'source-map',
25 | devServer: {
26 | hot: true,
27 | },
28 | output: {
29 | filename: 'app.js',
30 | },
31 | resolve: {
32 | extensions: [...(argv.mode !== 'development' ? [] : ['.dev.ts']), '.js', '.ts', '.tsx'],
33 | plugins: [new TSConfigPathsPlugin()],
34 | },
35 | module: {
36 | rules: [
37 | {
38 | test: /\.[mc]?[jt]sx?$/,
39 | exclude: [/node_modules\/core-js/],
40 | use: {
41 | loader: 'babel-loader',
42 | options: {
43 | sourceType: 'unambiguous',
44 | presets: [
45 | [
46 | '@babel/env',
47 | {
48 | useBuiltIns: 'usage',
49 | corejs: '3.37',
50 | targets: { chrome: 79 }, // corresponds to webOS 6
51 | },
52 | ],
53 | ['@babel/react'],
54 | ['@babel/typescript', { onlyRemoveTypeImports: true }],
55 | ],
56 | plugins: [
57 | ['transform-typescript-metadata'],
58 | ['@babel/plugin-proposal-decorators', { version: 'legacy' }],
59 | ['@babel/plugin-transform-class-properties'],
60 | ],
61 | },
62 | },
63 | },
64 | {
65 | test: /.scss$/,
66 | use: [
67 | MiniCssExtractPlugin.loader,
68 | 'css-loader',
69 | 'sass-loader',
70 | {
71 | loader: 'postcss-loader',
72 | options: {
73 | postcssOptions: {
74 | plugins: ['autoprefixer', 'postcss-preset-env'],
75 | },
76 | },
77 | },
78 | ],
79 | },
80 | {
81 | test: /.png$/,
82 | type: 'asset/resource',
83 | generator: {
84 | filename: 'assets/[hash][ext]',
85 | },
86 | },
87 | ],
88 | },
89 | performance: {
90 | hints: false,
91 | },
92 | plugins: [
93 | new ProvidePlugin({
94 | React: 'react',
95 | }),
96 | new DefinePlugin({
97 | __DEV__: JSON.stringify(argv.mode === 'development'),
98 | 'process.env.APP_ID': JSON.stringify(id),
99 | }),
100 | new MiniCssExtractPlugin(),
101 | new HtmlWebpackPlugin({
102 | template: './src/app/index.html',
103 | }),
104 | new CopyPlugin({
105 | patterns: [
106 | {
107 | from: '**/*',
108 | context: 'manifests/app',
109 | priority: 10,
110 | },
111 | {
112 | from: '**/*.json',
113 | context: 'manifests/app',
114 | transform: transformer.transform,
115 | priority: 0,
116 | },
117 | {
118 | from: 'agentd*',
119 | context: 'agent',
120 | to: 'service',
121 | toType: 'file',
122 | },
123 | ],
124 | }),
125 | ],
126 | });
127 |
128 | export default config;
129 |
--------------------------------------------------------------------------------
/webpack.config.ts:
--------------------------------------------------------------------------------
1 | import { hoc } from '@webosbrew/webos-packager-plugin';
2 |
3 | import { id, version } from './package.json';
4 | import app from './webpack.app';
5 | import service from './webpack.service';
6 |
7 | export default hoc({
8 | id,
9 | version,
10 | app,
11 | services: [service],
12 | });
13 |
--------------------------------------------------------------------------------
/webpack.service.ts:
--------------------------------------------------------------------------------
1 | import { DefinePlugin } from 'webpack';
2 |
3 | import CopyPlugin from 'copy-webpack-plugin';
4 |
5 | import { JsonTransformer } from 'webpack-utils';
6 | import type { WebpackConfigFunction } from 'webpack-utils';
7 |
8 | import { id, version } from './package.json';
9 |
10 | const SERVICE_ID = `${id}.service`;
11 |
12 | const transformer = new JsonTransformer({
13 | APP_ID: id,
14 | APP_VERSION: version,
15 | SERVICE_ID,
16 | });
17 |
18 | const config: WebpackConfigFunction<{ WEBPACK_SERVE?: boolean }> = (_, argv) => ({
19 | id: SERVICE_ID,
20 | name: 'service',
21 | mode: argv.mode ?? 'development',
22 | target: 'node8.12',
23 | entry: './service/index.ts',
24 | output: {
25 | filename: 'service.js',
26 | },
27 | externals: {
28 | palmbus: 'commonjs palmbus',
29 | },
30 | resolve: {
31 | extensions: ['.ts', '.js'],
32 | },
33 | module: {
34 | rules: [
35 | // TODO: move to Babel
36 | {
37 | test: /.[jt]sx?$/,
38 | loader: 'babel-loader',
39 | options: {
40 | presets: [
41 | ['@babel/env', { targets: { node: 12 } }],
42 | ['@babel/typescript', { onlyRemoveTypeImports: true }],
43 | ],
44 | },
45 | },
46 | {
47 | test: /.source.\w+$/,
48 | type: 'asset/source',
49 | loader: 'babel-loader',
50 | options: {
51 | presets: [
52 | [
53 | '@babel/env',
54 | {
55 | // surface manager uses a custom JS engine: QT V4
56 | // let's set target to something ancient just to cover ES3
57 | targets: { chrome: 4 },
58 | },
59 | ],
60 | ],
61 | plugins: [
62 | [
63 | 'minify-replace',
64 | {
65 | replacements: [
66 | {
67 | identifierName: '__APP_ID__',
68 | replacement: {
69 | type: 'stringLiteral',
70 | value: id,
71 | },
72 | },
73 | ],
74 | },
75 | ],
76 | ],
77 | },
78 | },
79 | ],
80 | },
81 | plugins: [
82 | new DefinePlugin({
83 | __DEV__: JSON.stringify(argv.mode === 'development'),
84 | 'process.env.APP_ID': JSON.stringify(id),
85 | 'process.env.SERVICE_ID': JSON.stringify(SERVICE_ID),
86 | }),
87 | new CopyPlugin({
88 | patterns: [
89 | {
90 | from: '*.json',
91 | context: 'manifests/service',
92 | transform: transformer.transform,
93 | },
94 | ],
95 | }),
96 | ],
97 | });
98 |
99 | export default config;
100 |
--------------------------------------------------------------------------------